diff --git a/doc/qbk/00_main.qbk b/doc/qbk/00_main.qbk index 520e2d3e9..2d3ce7c85 100644 --- a/doc/qbk/00_main.qbk +++ b/doc/qbk/00_main.qbk @@ -148,6 +148,16 @@ END [import ../../example/2_simple/patch_updates.cpp] [import ../../example/2_simple/source_script.cpp] [import ../../example/2_simple/pipeline.cpp] +[import ../../example/3_advanced/http_server_cpp20/main.cpp] +[import ../../example/3_advanced/http_server_cpp20/types.hpp] +[import ../../example/3_advanced/http_server_cpp20/error.hpp] +[import ../../example/3_advanced/http_server_cpp20/error.cpp] +[import ../../example/3_advanced/http_server_cpp20/repository.hpp] +[import ../../example/3_advanced/http_server_cpp20/repository.cpp] +[import ../../example/3_advanced/http_server_cpp20/handle_request.hpp] +[import ../../example/3_advanced/http_server_cpp20/handle_request.cpp] +[import ../../example/3_advanced/http_server_cpp20/server.hpp] +[import ../../example/3_advanced/http_server_cpp20/server.cpp] [import ../../example/3_advanced/connection_pool/main.cpp] [import ../../example/3_advanced/connection_pool/types.hpp] [import ../../example/3_advanced/connection_pool/repository.hpp] diff --git a/doc/qbk/21_examples.qbk b/doc/qbk/21_examples.qbk index c4246be0d..7d06790a4 100644 --- a/doc/qbk/21_examples.qbk +++ b/doc/qbk/21_examples.qbk @@ -331,7 +331,36 @@ This example assumes you have gone through the [link mysql.examples.setup setup] -[section:connection_pool A REST API server that uses connection pooling] +[section:http_server_cpp20 A REST API server that uses C++20 coroutines] + +This example assumes you have gone through the [link mysql.examples.setup setup]. + +[example_http_server_cpp20_main_cpp] + +[example_http_server_cpp20_types_hpp] + +[example_http_server_cpp20_error_hpp] + +[example_http_server_cpp20_error_cpp] + +[example_http_server_cpp20_repository_hpp] + +[example_http_server_cpp20_repository_cpp] + +[example_http_server_cpp20_handle_request_hpp] + +[example_http_server_cpp20_handle_request_cpp] + +[example_http_server_cpp20_server_hpp] + +[example_http_server_cpp20_server_cpp] + +[endsect] + + + + +[section:connection_pool A REST API server that uses asio::yield_context] This example assumes you have gone through the [link mysql.examples.setup setup]. diff --git a/example/3_advanced/http_server_cpp20/db_setup.sql b/example/3_advanced/http_server_cpp20/db_setup.sql new file mode 100644 index 000000000..8227d8e82 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/db_setup.sql @@ -0,0 +1,52 @@ +-- +-- Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +-- +-- Distributed under the Boost Software License, Version 1.0. (See accompanying +-- file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +-- + +-- Connection system variables +SET NAMES utf8; + +-- Database +DROP DATABASE IF EXISTS boost_mysql_orders; +CREATE DATABASE boost_mysql_orders; +USE boost_mysql_orders; + +-- User +DROP USER IF EXISTS 'orders_user'@'%'; +CREATE USER 'orders_user'@'%' IDENTIFIED BY 'orders_password'; +GRANT ALL PRIVILEGES ON boost_mysql_orders.* TO 'orders_user'@'%'; +FLUSH PRIVILEGES; + +-- Tables +CREATE TABLE products ( + id INT PRIMARY KEY AUTO_INCREMENT, + short_name VARCHAR(100) NOT NULL, + descr TEXT, + price INT NOT NULL, + FULLTEXT(short_name, descr) +); + +CREATE TABLE orders( + id INT PRIMARY KEY AUTO_INCREMENT, + `status` ENUM('draft', 'pending_payment', 'complete') NOT NULL DEFAULT 'draft' +); + +CREATE TABLE order_items( + id INT PRIMARY KEY AUTO_INCREMENT, + order_id INT NOT NULL, + product_id INT NOT NULL, + quantity INT NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + +-- Contents for the products table +INSERT INTO products (price, short_name, descr) VALUES + (6400, 'A Feast for Odin', 'A Feast for Odin is a points-driven game, with plethora of pathways to victory, with a range of risk balanced against reward. A significant portion of this is your central hall, which has a whopping -86 points of squares and a major part of your game is attempting to cover these up with various tiles. Likewise, long halls and island colonies can also offer large rewards, but they will have penalties of their own.'), + (1600, 'Railroad Ink', 'The critically acclaimed roll and write game where you draw routes on your board trying to connect the exits at its edges. The more you connect, the more points you make, but beware: each incomplete route will make you lose points!'), + (4000, 'Catan', 'Catan is a board game for two to four players in which you compete to gather resources and build the biggest settlements on the fictional island of Catan. It takes approximately one hour to play.'), + (2500, 'Not Alone', 'It is the 25th century. You are a member of an intergalactic expedition shipwrecked on a mysterious planet named Artemia. While waiting for the rescue ship, you begin to explore the planet but an alien entity picks up your scent and begins to hunt you. You are NOT ALONE! Will you survive the dangers of Artemia?'), + (4500, 'Dice Hospital', "In Dice Hospital, a worker placement board game, players are tasked with running a local hospital. Each round you'll be admitting new patients, hiring specialists, building new departments, and treating as many incoming patients as you can.") +; diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp new file mode 100644 index 000000000..6855ffc4e --- /dev/null +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -0,0 +1,69 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +//[example_http_server_cpp20_error_cpp + +#include + +#include +#include + +#include "error.hpp" + +namespace { + +// Converts an orders::errc to string +const char* error_to_string(orders::errc value) +{ + switch (value) + { + case orders::errc::not_found: return "not_found"; + case orders::errc::order_invalid_status: return "order_invalid_status"; + case orders::errc::product_not_found: return "product_not_found"; + default: return ""; + } +} + +// The category to be returned by get_orders_category +class orders_category final : public boost::system::error_category +{ +public: + // Identifies the error category. Used when converting error_codes to string + const char* name() const noexcept final override { return "orders"; } + + // Given a numeric error belonging to this category, convert it to a string + std::string message(int ev) const final override + { + return error_to_string(static_cast(ev)); + } +}; + +// The error category +static const orders_category g_category; + +// The std::mutex that guards std::cerr +static std::mutex g_cerr_mutex; + +} // namespace + +// +// External interface +// +const boost::system::error_category& orders::get_orders_category() { return g_category; } + +std::unique_lock orders::lock_cerr() { return std::unique_lock{g_cerr_mutex}; } + +void orders::log_error(std::string_view header, boost::system::error_code ec) +{ + // Lock the mutex + auto guard = lock_cerr(); + + // Logging the error code prints the number and category. Add the message, too + std::cerr << header << ": " << ec << " " << ec.message() << std::endl; +} + +//] diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp new file mode 100644 index 000000000..fdefe9178 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -0,0 +1,66 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_ERROR_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_ERROR_HPP + +//[example_http_server_cpp20_error_hpp +// +// File: error.hpp +// +// Contains an errc enumeration and the required pieces to +// use it with boost::system::error_code. +// We use this indirectly in the DB repository class, +// when using the error codes in boost::system::result. + +#include + +#include +#include +#include + +namespace orders { + +// Error code enum for errors originated within our application +enum class errc +{ + not_found, // couldn't retrieve or modify a certain resource because it doesn't exist + order_invalid_status, // an operation found an order in a status != the one expected (e.g. not editable) + product_not_found, // a product referenced by a request doesn't exist +}; + +// To use errc with boost::system::error_code, we need +// to define an error category (see the cpp file). +const boost::system::error_category& get_orders_category(); + +// Called when constructing an error_code from an errc value. +inline boost::system::error_code make_error_code(errc v) +{ + // Roughly, an error_code is an int and a category defining what the int means. + return boost::system::error_code(static_cast(v), get_orders_category()); +} + +// In multi-threaded programs, using std::cerr without any locking +// can result in interleaved output. +// Locks a mutex guarding std::cerr to prevent this. +// All uses of std::cerr should respect this. +std::unique_lock lock_cerr(); + +// A helper function for the common case where we want to log an error code +void log_error(std::string_view header, boost::system::error_code ec); + +} // namespace orders + +// This specialization is required to construct error_code's from errc values +template <> +struct boost::system::is_error_code_enum : std::true_type +{ +}; + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp new file mode 100644 index 000000000..60b3af32e --- /dev/null +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -0,0 +1,424 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED + +//[example_http_server_cpp20_handle_request_cpp +// +// File: handle_request.cpp +// + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "error.hpp" +#include "handle_request.hpp" +#include "repository.hpp" +#include "types.hpp" + +// This file contains all the boilerplate code to dispatch HTTP +// requests to API endpoints. Functions here end up calling +// note_repository fuctions. + +namespace asio = boost::asio; +namespace http = boost::beast::http; +namespace mysql = boost::mysql; +using boost::system::result; + +namespace { + +void log_mysql_error(boost::system::error_code ec, const mysql::diagnostics& diag) +{ + // Lock std::cerr, to avoid race conditions + auto guard = orders::lock_cerr(); + + // Inserting the error code only prints the number and category. Add the message, too. + std::cerr << "MySQL error: " << ec << " " << ec.message(); + + // client_message() contains client-side generated messages that don't + // contain user-input. This is usually embedded in exceptions. + // When working with error codes, we need to log it explicitly + if (!diag.client_message().empty()) + { + std::cerr << ": " << diag.client_message(); + } + + // server_message() contains server-side messages, and thus may + // contain user-supplied input. Printing it is safe. + if (!diag.server_message().empty()) + { + std::cerr << ": " << diag.server_message(); + } + + // Done + std::cerr << std::endl; +} + +// Attempts to parse a numeric ID from a string +std::optional parse_id(std::string_view from) +{ + std::int64_t id{}; + auto res = std::from_chars(from.data(), from.data() + from.size(), id); + if (res.ec != std::errc{} || res.ptr != from.data() + from.size()) + return std::nullopt; + return id; +} + +// Creates an error response +http::response error_response(http::status code, std::string_view msg) +{ + http::response res; + res.result(code); + res.body() = msg; + return res; +} + +// TODO +http::response bad_request(std::string body) +{ + return error_response(http::status::bad_request, std::move(body)); +} + +// Used when the user requested a note (e.g. using GET /note/ or PUT /note/) +// but the note doesn't exist +http::response not_found(std::string body = "The requested resource was not found") +{ + return error_response(http::status::not_found, std::move(body)); +} + +http::response unprocessable_entity(std::string body) +{ + return error_response(http::status::unprocessable_entity, std::move(body)); +} + +http::response internal_server_error() +{ + return error_response(http::status::internal_server_error, {}); +} + +// Creates a response with a serialized JSON body. +// T should be a type with Boost.Describe metadata containing the +// body data to be serialized +template +http::response json_response(const T& body) +{ + http::response res; + + // Set the content-type header + res.set("Content-Type", "application/json"); + + // Serialize the body data into a string and use it as the response body. + // We use Boost.JSON's automatic serialization feature, which uses Boost.Describe + // reflection data to generate a serialization function for us. + res.body() = boost::json::serialize(boost::json::value_from(body)); + + // Done + return res; +} + +// Attempts to parse a string as a JSON into an object of type T. +// T should be a type with Boost.Describe metadata. +// We use boost::system::result, which may contain a result or an error. +template +result parse_json(std::string_view json_string) +{ + // Attempt to parse the request into a json::value. + // This will fail if the provided body isn't valid JSON. + boost::system::error_code ec; + auto val = boost::json::parse(json_string, ec); + if (ec) + return ec; + + // Attempt to parse the json::value into a T. This will + // fail if the provided JSON doesn't match T's shape. + return boost::json::try_value_to(val); +} + +http::response response_from_db_error(boost::system::error_code ec) +{ + if (ec.category() == orders::get_orders_category()) + { + switch (static_cast(ec.value())) + { + case orders::errc::not_found: return not_found("The referenced entity does not exist"); + case orders::errc::product_not_found: + return unprocessable_entity("The referenced product does not exist"); + case orders::errc::order_invalid_status: + return unprocessable_entity( + "The referenced order doesn't have the status required by the operation" + ); + default: return internal_server_error(); + } + } + else + { + return internal_server_error(); + } +} + +// Contains data associated to an HTTP request. +// To be passed to individual handler functions +struct request_data +{ + // The incoming request + const http::request& request; + + // The URL the request is targeting + boost::urls::url_view target; + + // Connection pool + mysql::connection_pool& pool; + + orders::db_repository repo() const { return orders::db_repository(pool); } +}; + +// GET /products: search for available products +asio::awaitable> handle_get_products(const request_data& input) +{ + // Parse the query parameter + auto params_it = input.target.params().find("search"); + if (params_it == input.target.params().end()) + co_return bad_request("Missing mandatory query parameter: 'search'"); + auto search = (*params_it).value; + + // Invoke the database logic + std::vector products = co_await input.repo().get_products(search); + + // Return the response + co_return json_response(products); +} + +asio::awaitable> handle_get_orders(const request_data& input) +{ + // Parse the query parameter + auto params_it = input.target.params().find("id"); + + if (params_it == input.target.params().end()) + { + // If the query parameter is not present, return all orders + // Invoke the database logic + std::vector orders = co_await input.repo().get_orders(); + + // Return the response + co_return json_response(orders); + } + else + { + // Otherwise, query by ID + // Parse the query parameter + auto order_id = parse_id((*params_it).value); + if (!order_id.has_value()) + co_return bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result order = co_await input.repo().get_order_by_id(*order_id); + if (order.has_error()) + co_return response_from_db_error(order.error()); + + // Return the response + co_return json_response(*order); + } +} + +asio::awaitable> handle_create_order(const request_data& input) +{ + // Invoke the database logic + orders::order_with_items order = co_await input.repo().create_order(); + + // Return the response + co_return json_response(order); +} + +asio::awaitable> handle_add_order_item(const request_data& input) +{ + // Check that the request has the appropriate content type + auto it = input.request.find("Content-Type"); + if (it == input.request.end() || it->value() != "application/json") + co_return bad_request("Invalid Content-Type: expected 'application/json'"); + + // Parse the request body + auto req = parse_json(input.request.body()); + if (req.has_error()) + co_return bad_request("Invalid JSON body"); + + // Invoke the database logic + result res = co_await input.repo() + .add_order_item(req->order_id, req->product_id, req->quantity); + if (res.has_error()) + co_return response_from_db_error(res.error()); + + // Return the response + co_return json_response(*res); +} + +asio::awaitable> handle_remove_order_item(const request_data& input) +{ + // Parse the query parameter + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) + co_return bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await input.repo().remove_order_item(*id); + if (res.has_error()) + co_return response_from_db_error(res.error()); + + // Return the response + co_return json_response(*res); +} + +asio::awaitable> handle_checkout_order(const request_data& input) +{ + // Parse the query parameter + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) + co_return bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await input.repo().checkout_order(*id); + if (res.has_error()) + co_return response_from_db_error(res.error()); + + // Return the response + co_return json_response(*res); +} + +asio::awaitable> handle_complete_order(const request_data& input) +{ + // Parse the query parameter + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) + co_return bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await input.repo().complete_order(*id); + if (res.has_error()) + co_return response_from_db_error(res.error()); + + // Return the response + co_return json_response(*res); +} + +struct http_endpoint +{ + http::verb method; + asio::awaitable> (*handler)(const request_data&); +}; + +const std::unordered_multimap endpoints{ + {"/products", {http::verb::get, &handle_get_products} }, + {"/orders", {http::verb::get, &handle_get_orders} }, + {"/orders", {http::verb::post, &handle_create_order} }, + {"/orders/items", {http::verb::post, &handle_add_order_item} }, + {"/orders/items", {http::verb::delete_, &handle_remove_order_item}}, + {"/orders/checkout", {http::verb::post, &handle_checkout_order} }, + {"/orders/complete", {http::verb::post, &handle_complete_order} }, +}; + +} // namespace + +// External interface +asio::awaitable> orders::handle_request( + const http::request& request, + mysql::connection_pool& pool +) +{ + // Parse the request target + auto target = boost::urls::parse_origin_form(request.target()); + if (!target.has_value()) + co_return bad_request("Invalid request target"); + + // Try to find an endpoint + auto [it1, it2] = endpoints.equal_range(target->path()); + if (it1 == endpoints.end()) + co_return not_found("The request endpoint does not exist"); + + // Match the verb + auto it3 = std::find_if(it1, it2, [&request](const std::pair& ep) { + return ep.second.method == request.method(); + }); + if (it3 == it2) + co_return error_response(http::status::method_not_allowed, "Unsupported HTTP method"); + + // Compose the data struct (TODO) + request_data h{request, *target, pool}; + + // Invoke the handler + try + { + // Attempt to handle the request + co_return co_await it3->second.handler(h); + } + catch (const mysql::error_with_diagnostics& err) + { + // A Boost.MySQL error. This will happen if you don't have connectivity + // to your database, your schema is incorrect or your credentials are invalid. + // Log the error, including diagnostics + log_mysql_error(err.code(), err.get_diagnostics()); + + // Never disclose error info to a potential attacker + co_return internal_server_error(); + } + catch (const std::exception& err) + { + // Another kind of error. This indicates a programming error or a severe + // server condition (e.g. out of memory). Same procedure as above. + { + auto guard = orders::lock_cerr(); + std::cerr << "Uncaught exception: " << err.what() << std::endl; + } + co_return internal_server_error(); + } +} + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/handle_request.hpp b/example/3_advanced/http_server_cpp20/handle_request.hpp new file mode 100644 index 000000000..5a8b80469 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/handle_request.hpp @@ -0,0 +1,34 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP + +//[example_http_server_cpp20_handle_request_hpp +// +// File: handle_request.hpp +// + +#include + +#include +#include +#include + +namespace orders { + +// Handles an individual HTTP request, producing a response. +boost::asio::awaitable> handle_request( + const boost::beast::http::request& request, + boost::mysql::connection_pool& pool +); + +} // namespace orders + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp new file mode 100644 index 000000000..f12f57a77 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -0,0 +1,177 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED + +//[example_http_server_cpp20_main_cpp + +/** + * TODO: review this + * This example demonstrates how to use a connection_pool. + * It implements a minimal REST API to manage notes. + * A note is a simple object containing a user-defined title and content. + * The REST API offers CRUD operations on such objects: + * GET /products?search={s} Returns a list of products + * GET /orders Returns all orders + * GET /orders?id={} Returns a single order + * POST /orders Creates a new order. + * POST /orders/items Adds a new order item to an existing order. + * DELETE /orders/items?id={} Deletes an order item + * POST /orders/checkout?id={} Checks out an order + * POST /orders/complete?id={} Completes an order + * + * Notes are stored in MySQL. The note_repository class encapsulates + * access to MySQL, offering friendly functions to manipulate notes. + * server.cpp encapsulates all the boilerplate to launch an HTTP server, + * match URLs to API endpoints, and invoke the relevant note_repository functions. + * All communication happens asynchronously. We use stackful coroutines to simplify + * development, using boost::asio::spawn and boost::asio::yield_context. + * This example requires linking to Boost::context, Boost::json and Boost::url. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "server.hpp" + +using namespace orders; +namespace mysql = boost::mysql; +namespace asio = boost::asio; + +// The number of threads to use +static constexpr std::size_t num_threads = 5; + +int main_impl(int argc, char* argv[]) +{ + // Check command line arguments. + if (argc != 5) + { + std::cerr << "Usage: " << argv[0] << " \n"; + return EXIT_FAILURE; + } + + // Application config + const char* mysql_username = argv[1]; + const char* mysql_password = argv[2]; + const char* mysql_hostname = argv[3]; + auto port = static_cast(std::stoi(argv[4])); + + // An event loop, where the application will run. + // We will use the main thread to run the pool, too, so we use + // one thread less than configured + asio::thread_pool th_pool(num_threads - 1); + + // Create a connection pool + mysql::connection_pool pool( + // Use the thread pool as execution context + th_pool, + + // Pool configuration + mysql::pool_params{ + // Connect using TCP, to the given hostname and using the default port + .server_address = mysql::host_and_port{mysql_hostname}, + + // Authenticate using the given username + .username = mysql_username, + + // Password for the above username + .password = mysql_password, + + // Database to use when connecting + .database = "boost_mysql_orders", + + // We're using multi-queries + .multi_queries = true, + + // Using thread_safe will make the pool thread-safe by internally + // creating and using a strand. + // This allows us to share the pool between sessions, which may run + // concurrently, on different threads. + .thread_safe = true, + } + ); + + // Launch the MySQL pool + pool.async_run(asio::detached); + + // A signal_set allows us to intercept SIGINT and SIGTERM and + // exit gracefully + asio::signal_set signals{th_pool.get_executor(), SIGINT, SIGTERM}; + + // Capture SIGINT and SIGTERM to perform a clean shutdown + signals.async_wait([&th_pool](boost::system::error_code, int) { + // Stop the execution context. This will cause main to exit + th_pool.stop(); + }); + + // Start listening for HTTP connections. This will run until the context is stopped + asio::co_spawn( + th_pool, + [&pool, port] { return listener(pool, port); }, + [](std::exception_ptr exc) { + if (exc) + std::rethrow_exception(exc); + } + ); + + // Attach the current thread to the thread pool. This will block + // until stop() is called + th_pool.attach(); + + // Wait until all threads have exited + th_pool.join(); + + std::cout << "Server exiting" << std::endl; + + // (If we get here, it means we got a SIGINT or SIGTERM) + return EXIT_SUCCESS; +} + +int main(int argc, char** argv) +{ + try + { + main_impl(argc, argv); + } + catch (const std::exception& err) + { + std::cerr << "Error: " << err.what() << std::endl; + return 1; + } +} + +//] + +#else + +#include + +int main() +{ + std::cout << "Sorry, your compiler doesn't have the required capabilities to run this example" + << std::endl; +} + +#endif diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp new file mode 100644 index 000000000..2b95ca2d2 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -0,0 +1,368 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED + +//[example_http_server_cpp20_repository_cpp +// +// File: repository.cpp +// +// See the db_setup.sql file in this folder for the table definitions + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include "error.hpp" +#include "repository.hpp" +#include "types.hpp" + +namespace mysql = boost::mysql; +namespace asio = boost::asio; +using namespace orders; + +asio::awaitable> db_repository::get_products(std::string_view search) +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Get the products using the MySQL built-in full-text search feature. + // Look for the query string in the short_name and descr fields. + // Parse the query results into product struct instances + mysql::static_results res; + co_await conn->async_execute( + mysql::with_params( + "SELECT id, short_name, descr, price FROM products " + "WHERE MATCH(short_name, descr) AGAINST({}) " + "LIMIT 10", + search + ), + res + ); + + // By default, connections are reset after they are returned to the pool + // (by using any_connection::async_reset_connection). This will reset any + // session state we changed while we were using the connection + // (e.g. it will deallocate any statements we prepared). + // We did nothing to mutate session state, so we can tell the pool to skip + // this step, providing a minor performance gain. + // We use pooled_connection::return_without_reset to do this. + // If an exception was raised, the connection would be reset, for safety. + conn.return_without_reset(); + + // Return the result + co_return std::vector{res.rows().begin(), res.rows().end()}; +} + +asio::awaitable> db_repository::get_orders() +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Get all the orders. + // Parse the result into order structs. + mysql::static_results res; + co_await conn->async_execute("SELECT id, status FROM orders", res); + + // We didn't mutate session state, so we can skip resetting the connection + conn.return_without_reset(); + + // Return the result + co_return std::vector{res.rows().begin(), res.rows().end()}; +} + +asio::awaitable> db_repository::get_order_by_id(std::int64_t id) +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Get a single order and all its associated items. + // The transaction ensures atomicity between the two SELECTs. + // We issued 4 queries, so we get 4 resultsets back. + // Ignore the 1st and 4th, and parse the other two into order and order_item structs + mysql::static_results, order, order_item, std::tuple<>> result; + co_await conn->async_execute( + mysql::with_params( + "START TRANSACTION READ ONLY;" + "SELECT id, status FROM orders WHERE id = {0};" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {0};" + "COMMIT", + id + ), + result + ); + + // We didn't mutate session state + conn.return_without_reset(); + + // result.rows returns the rows for the N-th resultset, as a span + auto orders = result.rows<1>(); + auto order_items = result.rows<2>(); + + // Did we find the order we're looking for? + if (orders.empty()) + co_return orders::errc::not_found; + const order& ord = orders[0]; + + // If we did, compose the result + co_return order_with_items{ + ord.id, + ord.status, + {order_items.begin(), order_items.end()} + }; +} + +asio::awaitable db_repository::create_order() +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Create the new order. + // Orders are created empty, with all fields defaulted. + // MySQL does not have an INSERT ... RETURNING statement, so we use + // a transaction with an INSERT and a SELECT to create the order + // and retrieve it atomically. + // This yields 4 resultsets, one per SQL statement. + // Ignore all except the SELECT, and parse it into an order struct. + mysql::static_results, std::tuple<>, order, std::tuple<>> result; + co_await conn->async_execute( + "START TRANSACTION;" + "INSERT INTO orders () VALUES ();" + "SELECT id, status FROM orders WHERE id = LAST_INSERT_ID();" + "COMMIT", + result + ); + + // We didn't mutate session state + conn.return_without_reset(); + + // This must always yield one row. Return it. + const order& ord = result.rows<2>().front(); + co_return order_with_items{ + ord.id, + ord.status, + {} // A newly created order never has items + }; +} + +asio::awaitable> db_repository::add_order_item( + std::int64_t order_id, + std::int64_t product_id, + std::int64_t quantity +) +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Retrieve the order and the product. + // SELECT ... FOR SHARE places a shared lock on the retrieved rows, + // so they're not modified by other transactions while we use them. + // For the product, we only need to check that it does exist, + // so we get its ID and parse the returned rows into a std::tuple. + mysql::static_results, order, std::tuple> result1; + co_await conn->async_execute( + mysql::with_params( + "START TRANSACTION;" + "SELECT id, status FROM orders WHERE id = {} FOR SHARE;" + "SELECT id FROM products WHERE id = {} FOR SHARE", + order_id, + product_id + ), + result1 + ); + + // Check that the order exists + if (result1.rows<1>().empty()) + { + // Not found. We did mutate session state by opening a transaction, + // so we can't use return_without_reset + co_return orders::errc::not_found; + } + const order& ord = result1.rows<1>().front(); + + // Verify that the order is editable. + // Using SELECT ... FOR SHARE prevents race conditions with this check. + if (ord.status != status_draft) + { + co_return orders::errc::order_invalid_status; + } + + // Check that the product exists + if (result1.rows<2>().empty()) + { + co_return orders::errc::product_not_found; + } + + // Insert the new item and retrieve all the items associated to this order + mysql::static_results, order_item, std::tuple<>> result2; + co_await conn->async_execute( + mysql::with_params( + "INSERT INTO order_items (order_id, product_id, quantity) VALUES ({0}, {1}, {2});" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {0};" + "COMMIT", + order_id, + product_id, + quantity + ), + result2 + ); + + // If everything went well, we didn't mutate session state + conn.return_without_reset(); + + // Compose the return value + co_return order_with_items{ + ord.id, + ord.status, + {result2.rows<1>().begin(), result2.rows<1>().end()} + }; +} + +asio::awaitable> db_repository::remove_order_item(std::int64_t item_id +) +{ + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); + + // Retrieve the order. + // SELECT ... FOR SHARE places a shared lock on the order and the item, + // so they're not modified by other transactions while we use them. + mysql::static_results, order> result1; + co_await conn->async_execute( + mysql::with_params( + "START TRANSACTION;" + "SELECT ord.id AS id, status FROM orders ord" + " JOIN order_items it ON (ord.id = it.order_id)" + " WHERE it.id = {} FOR SHARE", + item_id + ), + result1 + ); + + // Check that the item exists + if (result1.rows<1>().empty()) + { + // Not found. We did mutate session state by opening a transaction, + // so we can't use return_without_reset + co_return orders::errc::not_found; + } + const order& ord = result1.rows<1>().front(); + + // Check that the order is editable + if (ord.status != orders::status_draft) + { + co_return orders::errc::order_invalid_status; + } + + // Perform the deletion and retrieve the items + mysql::static_results, order_item, std::tuple<>> result2; + co_await conn->async_execute( + mysql::with_params( + "DELETE FROM order_items WHERE id = {};" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {};" + "COMMIT", + item_id, + ord.id + ), + result2 + ); + + // If everything went well, we didn't mutate session state + conn.return_without_reset(); + + // Compose the return value + co_return order_with_items{ + ord.id, + ord.status, + {result2.rows<1>().begin(), result2.rows<1>().end()} + }; +} + +// Helper function to implement checkout_order and complete_order +static asio::awaitable> change_order_status( + mysql::connection_pool& pool, + std::int64_t order_id, + std::string_view original_status, // The status that the order should have + std::string_view target_status // The status to transition the order to +) +{ + // Get a connection from the pool + auto conn = co_await pool.async_get_connection(); + + // Retrieve the order and lock it. + // FOR UPDATE places an exclusive lock on the order, + // preventing other concurrent transactions (including the ones + // related to adding/removing items) from changing the order + mysql::static_results, std::tuple> result1; + co_await conn->async_execute( + mysql::with_params( + "START TRANSACTION;" + "SELECT status FROM orders WHERE id = {} FOR UPDATE;", + order_id + ), + result1 + ); + + // Check that the order exists + if (result1.rows<1>().empty()) + { + co_return orders::errc::not_found; + } + + // Check that the order is in the expected status + if (std::get<0>(result1.rows<1>().front()) != original_status) + { + co_return orders::errc::order_invalid_status; + } + + // Update the order and retrieve the order details + mysql::static_results, order_item, std::tuple<>> result2; + co_await conn->async_execute( + mysql::with_params( + "UPDATE orders SET status = {1} WHERE id = {0};" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {0};" + "COMMIT", + order_id, + target_status + ), + result2 + ); + + // If everything went well, we didn't mutate session state + conn.return_without_reset(); + + // Compose the return value + co_return order_with_items{ + order_id, + std::string(target_status), + {result2.rows<1>().begin(), result2.rows<1>().end()} + }; +} + +asio::awaitable> db_repository::checkout_order(std::int64_t id) +{ + return change_order_status(pool_, id, status_draft, status_pending_payment); +} + +asio::awaitable> db_repository::complete_order(std::int64_t id) +{ + return change_order_status(pool_, id, status_pending_payment, status_complete); +} + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/repository.hpp b/example/3_advanced/http_server_cpp20/repository.hpp new file mode 100644 index 000000000..df1e079a4 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/repository.hpp @@ -0,0 +1,83 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP + +//[example_http_server_cpp20_repository_hpp +// +// File: repository.hpp +// + +#include + +#include +#include + +#include +#include +#include + +#include "types.hpp" + +namespace orders { + +// Encapsulates database logic. +// If the database is unavailable, all of these functions throw. +// Additionally, functions that may fail depending on the supplied input +// return boost::system::result to avoid exceptions in common cases. +class db_repository +{ + boost::mysql::connection_pool& pool_; + +public: + // Constructor (this is a cheap-to-construct object) + db_repository(boost::mysql::connection_pool& pool) noexcept : pool_(pool) {} + + // Retrieves products using a full-text search + boost::asio::awaitable> get_products(std::string_view search); + + // Retrieves all the orders in the database + boost::asio::awaitable> get_orders(); + + // Retrieves an order by ID. + // Returns an error if the ID doesn't match any order. + boost::asio::awaitable> get_order_by_id(std::int64_t id); + + // Creates an empty order. Returns the created order. + boost::asio::awaitable create_order(); + + // Adds an item to an order. Retrieves the updated order. + // Returns an error if the ID doesn't match any order, the order + // is not editable, or the product_id doesn't match any product + boost::asio::awaitable> add_order_item( + std::int64_t order_id, + std::int64_t product_id, + std::int64_t quantity + ); + + // Removes an item from an order. Retrieves the updated order. + // Returns an error if the ID doesn't match any order item + // or the order is not editable. + boost::asio::awaitable> remove_order_item(std::int64_t item_id); + + // Checks an order out, transitioning it to the pending_payment status. + // Returns an error if the ID doesn't match any order + // or the order is not editable. + boost::asio::awaitable> checkout_order(std::int64_t id); + + // Completes an order, transitioning it to the complete status. + // Returns an error if the ID doesn't match any order + // or the order is not checked out. + boost::asio::awaitable> complete_order(std::int64_t id); +}; + +} // namespace orders + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/server.cpp b/example/3_advanced/http_server_cpp20/server.cpp new file mode 100644 index 000000000..4718e9119 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/server.cpp @@ -0,0 +1,213 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED + +//[example_http_server_cpp20_server_cpp +// +// File: server.cpp +// +// This file contains all the boilerplate code to implement a HTTP +// server. Functions here end up invoking handle_request. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "error.hpp" +#include "handle_request.hpp" +#include "server.hpp" + +namespace asio = boost::asio; +namespace http = boost::beast::http; +namespace mysql = boost::mysql; + +namespace { + +struct http_endpoint +{ + std::vector segments; + http::verb method; +}; + +static asio::awaitable run_http_session(asio::ip::tcp::socket sock, mysql::connection_pool& pool) +{ + using namespace std::chrono_literals; + + boost::system::error_code ec; + + // A buffer to read incoming client requests + boost::beast::flat_buffer buff; + + asio::steady_timer timer(co_await asio::this_coro::executor); + + while (true) + { + // Construct a new parser for each message + http::request_parser parser; + + // Apply a reasonable limit to the allowed size + // of the body in bytes to prevent abuse. + parser.body_limit(10000); + + // Read a request + co_await http::async_read( + sock, + buff, + parser.get(), + asio::cancel_after(timer, 60s, asio::redirect_error(ec)) + ); + + if (ec) + { + if (ec == http::error::end_of_stream) + { + // This means they closed the connection + sock.shutdown(asio::ip::tcp::socket::shutdown_send, ec); + } + else + { + // An unknown error happened + orders::log_error("Error reading HTTP request: ", ec); + } + co_return; + } + + const auto& request = parser.get(); + + // Process the request to generate a response. + // This invokes the business logic, which will need to access MySQL data + auto response = co_await asio::co_spawn( + co_await asio::this_coro::executor, + [&] { return orders::handle_request(request, pool); }, + asio::cancel_after(timer, 30s) + ); + + // Determine if we should close the connection + bool keep_alive = response.keep_alive(); + response.version(request.version()); + response.keep_alive(keep_alive); + response.prepare_payload(); + + // Send the response + co_await http::async_write(sock, response, asio::cancel_after(timer, 60s, asio::redirect_error(ec))); + if (ec) + { + orders::log_error("Error writing HTTP response: ", ec); + co_return; + } + + // This means we should close the connection, usually because + // the response indicated the "Connection: close" semantic. + if (!keep_alive) + { + sock.shutdown(asio::ip::tcp::socket::shutdown_send, ec); + co_return; + } + } +} + +} // namespace + +asio::awaitable orders::listener(mysql::connection_pool& pool, unsigned short port) +{ + // An object that allows us to accept incoming TCP connections. + // Since we're in a multi-threaded environment, we create a strand for the acceptor, + // so all accept handlers are run serialized + asio::ip::tcp::acceptor acc(asio::make_strand(co_await asio::this_coro::executor)); + + // The endpoint where the server will listen. Edit this if you want to + // change the address or port we bind to. + asio::ip::tcp::endpoint listening_endpoint(asio::ip::make_address("0.0.0.0"), port); + + // Open the acceptor + acc.open(listening_endpoint.protocol()); + + // Allow address reuse + acc.set_option(asio::socket_base::reuse_address(true)); + + // Bind to the server address + acc.bind(listening_endpoint); + + // Start listening for connections + acc.listen(asio::socket_base::max_listen_connections); + + std::cout << "Server listening at " << acc.local_endpoint() << std::endl; + + // Start the acceptor loop + while (true) + { + // Accept a new connection + auto [ec, sock] = co_await acc.async_accept(asio::as_tuple); + + // If there was an error accepting the connection, exit our loop + if (ec) + { + log_error("Error while accepting connection", ec); + co_return; + } + + // TODO: document this + auto session_logic = [&pool, socket = std::move(sock)]() mutable { + return run_http_session(std::move(socket), pool); + }; + + // Launch a new session for this connection. Each session gets its + // own coroutine, so we can get back to listening for new connections. + asio::co_spawn( + // Every session gets its own strand. This prevents data races. + asio::make_strand(co_await asio::this_coro::executor), + + // The actual coroutine + std::move(session_logic), + + // All errors in the session are handled via error codes or by catching + // exceptions explicitly. An unhandled exception here means an error. + // Rethrowing it will propagate the exception, making io_context::run() + // to throw and terminate the program. + [](std::exception_ptr ex) { + if (ex) + std::rethrow_exception(ex); + } + ); + } +} + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/server.hpp b/example/3_advanced/http_server_cpp20/server.hpp new file mode 100644 index 000000000..579027856 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/server.hpp @@ -0,0 +1,34 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_SERVER_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_SERVER_HPP + +//[example_http_server_cpp20_server_hpp +// +// File: server.hpp +// + +#include + +#include + +namespace orders { + +// TODO: review +// Launches a HTTP server that will listen on 0.0.0.0:port. +// If the server fails to launch (e.g. because the port is aleady in use), +// returns a non-zero error code. ex should identify the io_context or thread_pool +// where the server should run. The server is run until the underlying execution +// context is stopped. +boost::asio::awaitable listener(boost::mysql::connection_pool& pool, unsigned short port); + +} // namespace orders + +//] + +#endif diff --git a/example/3_advanced/http_server_cpp20/types.hpp b/example/3_advanced/http_server_cpp20/types.hpp new file mode 100644 index 000000000..d16caf91b --- /dev/null +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -0,0 +1,95 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP + +//[example_http_server_cpp20_types_hpp +// +// File: types.hpp +// + +#include + +#include +#include +#include +#include +#include + +// TODO: review +// Contains type definitions used in the REST API and database code. +// We use Boost.Describe (BOOST_DESCRIBE_STRUCT) to add reflection +// capabilities to our types. This allows using Boost.MySQL +// static interface (i.e. static_results) to parse query results, +// and Boost.JSON automatic serialization/deserialization. + +namespace orders { + +struct product +{ + // The unique database ID of the object. + std::int64_t id; + + // The product's display name + std::string short_name; + + // The product's description + std::optional descr; + + // The product's price, in dollar cents + std::int64_t price; +}; +BOOST_DESCRIBE_STRUCT(product, (), (id, short_name, descr, price)) + +struct order +{ + std::int64_t id; + std::string status; +}; +BOOST_DESCRIBE_STRUCT(order, (), (id, status)) + +inline constexpr std::string_view status_draft = "draft"; +inline constexpr std::string_view status_pending_payment = "pending_payment"; +inline constexpr std::string_view status_complete = "complete"; + +struct order_item +{ + std::int64_t id; + std::int64_t product_id; + std::int64_t quantity; +}; +BOOST_DESCRIBE_STRUCT(order_item, (), (id, product_id, quantity)) + +struct order_with_items +{ + std::int64_t id; + std::string status; + std::vector items; +}; +BOOST_DESCRIBE_STRUCT(order_with_items, (), (id, status, items)) + +// +// REST API requests. +// +struct add_order_item_request +{ + std::int64_t order_id; + std::int64_t product_id; + std::int64_t quantity; +}; +BOOST_DESCRIBE_STRUCT(add_order_item_request, (), (order_id, product_id, quantity)) + +// +// REST API responses. +// + +} // namespace orders + +//] + +#endif diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index 681844e28..35afa16b4 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -113,3 +113,20 @@ add_example( PYTHON_RUNNER run_connection_pool.py ARGS ${SERVER_HOST} ) + +add_example( + http_server_cpp20 + SOURCES + 3_advanced/http_server_cpp20/error.cpp + 3_advanced/http_server_cpp20/repository.cpp + 3_advanced/http_server_cpp20/handle_request.cpp + 3_advanced/http_server_cpp20/server.cpp + 3_advanced/http_server_cpp20/main.cpp + LIBS + Boost::json + Boost::url + Boost::beast + Boost::pfr + PYTHON_RUNNER run_orders.py + ARGS ${SERVER_HOST} +) diff --git a/example/Jamfile b/example/Jamfile index 96422adfa..4775f435e 100644 --- a/example/Jamfile +++ b/example/Jamfile @@ -120,3 +120,16 @@ run_example connection_pool : norecover:no enable:no ; + +run_example http_server_cpp20 : + 3_advanced/http_server_cpp20/main.cpp + 3_advanced/http_server_cpp20/repository.cpp + 3_advanced/http_server_cpp20/handle_request.cpp + 3_advanced/http_server_cpp20/server.cpp + 3_advanced/http_server_cpp20/error.cpp + /boost/mysql/test//boost_json_lib + /boost/url//boost_url + /boost/mysql/test//boost_beast_lib + : $(hostname) + : run_orders.py + ; diff --git a/example/db_setup.sql b/example/db_setup.sql index fade08ccd..f360ac38d 100644 --- a/example/db_setup.sql +++ b/example/db_setup.sql @@ -86,300 +86,3 @@ CREATE USER 'example_user'@'%' IDENTIFIED WITH 'mysql_native_password'; ALTER USER 'example_user'@'%' IDENTIFIED BY 'example_password'; GRANT ALL PRIVILEGES ON boost_mysql_examples.* TO 'example_user'@'%'; FLUSH PRIVILEGES; - - --- Tables for the orders examples -CREATE TABLE products ( - id INT PRIMARY KEY AUTO_INCREMENT, - short_name VARCHAR(100) NOT NULL, - descr TEXT, - price INT NOT NULL, - FULLTEXT(short_name, descr) -); - -CREATE TABLE orders( - id INT PRIMARY KEY AUTO_INCREMENT, - `status` ENUM('draft', 'pending_payment', 'complete') NOT NULL DEFAULT 'draft' -); - -CREATE TABLE order_items( - id INT PRIMARY KEY AUTO_INCREMENT, - order_id INT NOT NULL, - product_id INT NOT NULL, - quantity INT NOT NULL, - FOREIGN KEY (order_id) REFERENCES orders(id), - FOREIGN KEY (product_id) REFERENCES products(id) -); - --- Procedures for the orders examples -DELIMITER // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_products(IN p_search VARCHAR(50)) -BEGIN - DECLARE max_products INT DEFAULT 20; - IF p_search IS NULL THEN - SELECT id, short_name, descr, price - FROM products - LIMIT max_products; - ELSE - SELECT id, short_name, descr, price FROM products - WHERE MATCH(short_name, descr) AGAINST(p_search) - LIMIT max_products; - END IF; -END // - -CREATE PROCEDURE create_order() -BEGIN - START TRANSACTION; - - -- Create the order - INSERT INTO orders () VALUES (); - - -- Return the order - SELECT id, `status` - FROM orders - WHERE id = LAST_INSERT_ID(); - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_order( - IN p_order_id INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION READ ONLY; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'get_order: invalid parameters'; - END IF; - - -- Check that the order exists - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - - -- Return the order. The IFNULL statements make MySQL correctly report the fields as non-NULL - SELECT - IFNULL(p_order_id, 0) AS id, - IFNULL(order_status, 'draft') AS `status`; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_orders() -BEGIN - SELECT id, `status` FROM orders; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE add_line_item( - IN p_order_id INT, - IN p_product_id INT, - IN p_quantity INT, - OUT pout_line_item_id INT -) -BEGIN - DECLARE product_price INT; - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL OR p_product_id IS NULL OR p_quantity IS NULL OR p_quantity <= 0 THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'add_line_item: invalid params'; - END IF; - - -- Ensure that the product is valid - SELECT price INTO product_price FROM products WHERE id = p_product_id; - IF product_price IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given product does not exist'; - END IF; - - -- Get the order - SELECT `status` INTO order_status FROM orders WHERE id = p_order_id; - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not editable'; - END IF; - - -- Insert the new item - INSERT INTO order_items (order_id, product_id, quantity) VALUES (p_order_id, p_product_id, p_quantity); - - -- Return value - SET pout_line_item_id = LAST_INSERT_ID(); - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE remove_line_item( - IN p_line_item_id INT -) -BEGIN - DECLARE order_id INT; - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_line_item_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'remove_line_item: invalid params'; - END IF; - - -- Get the order - SELECT orders.id, orders.`status` - INTO order_id, order_status - FROM orders - JOIN order_items items ON (orders.id = items.order_id) - WHERE items.id = p_line_item_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order item does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not editable'; - END IF; - - -- Delete the line item - DELETE FROM order_items - WHERE id = p_line_item_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE checkout_order( - IN p_order_id INT, - OUT pout_order_total INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'checkout_order: invalid params'; - END IF; - - -- Get the order - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not in a state that can be checked out'; - END IF; - - -- Update the order - UPDATE orders SET `status` = 'pending_payment' WHERE id = p_order_id; - - -- Retrieve the total price - SELECT SUM(prod.price * item.quantity) - INTO pout_order_total - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - - -CREATE DEFINER = 'example_user'@'%' PROCEDURE complete_order( - IN p_order_id INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'complete_order: invalid params'; - END IF; - - -- Get the order - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'pending_payment' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not in a state that can be completed'; - END IF; - - -- Update the order - UPDATE orders SET `status` = 'complete' WHERE id = p_order_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -DELIMITER ; - --- Create an order, at least -INSERT INTO orders () VALUES (); - --- Contents for the products table -INSERT INTO products (price, short_name, descr) VALUES - (6400, 'A Feast for Odin', 'A Feast for Odin is a points-driven game, with plethora of pathways to victory, with a range of risk balanced against reward. A significant portion of this is your central hall, which has a whopping -86 points of squares and a major part of your game is attempting to cover these up with various tiles. Likewise, long halls and island colonies can also offer large rewards, but they will have penalties of their own.'), - (1600, 'Railroad Ink', 'The critically acclaimed roll and write game where you draw routes on your board trying to connect the exits at its edges. The more you connect, the more points you make, but beware: each incomplete route will make you lose points!'), - (4000, 'Catan', 'Catan is a board game for two to four players in which you compete to gather resources and build the biggest settlements on the fictional island of Catan. It takes approximately one hour to play.'), - (2500, 'Not Alone', 'It is the 25th century. You are a member of an intergalactic expedition shipwrecked on a mysterious planet named Artemia. While waiting for the rescue ship, you begin to explore the planet but an alien entity picks up your scent and begins to hunt you. You are NOT ALONE! Will you survive the dangers of Artemia?'), - (4500, 'Dice Hospital', "In Dice Hospital, a worker placement board game, players are tasked with running a local hospital. Each round you'll be admitting new patients, hiring specialists, building new departments, and treating as many incoming patients as you can.") -; diff --git a/example/private/run_orders.py b/example/private/run_orders.py new file mode 100644 index 000000000..0c618ef49 --- /dev/null +++ b/example/private/run_orders.py @@ -0,0 +1,380 @@ +#!/usr/bin/python3 +# +# Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# + +import requests +import argparse +from subprocess import PIPE, STDOUT, Popen +from contextlib import contextmanager +import re +import os +import unittest +import copy +import sys + + +_is_win = os.name == 'nt' + + +# Returns the port the server is listening at +def _parse_server_start_line(line: str) -> int: + m = re.match(r'Server listening at 0\.0\.0\.0:([0-9]+)', line) + if m is None: + raise RuntimeError('Unexpected server start line') + return int(m.group(1)) + + +@contextmanager +def _launch_server(exe: str, host: str): + # Launch server and let it choose a free port for us. + # This prevents port clashes during b2 parallel test runs + server = Popen([exe, 'orders_user', 'orders_password', host, '0'], stdout=PIPE, stderr=STDOUT) + assert server.stdout is not None + with server: + try: + # Wait until the server is ready + ready_line = server.stdout.readline().decode() + print(ready_line, end='', flush=True) + if ready_line.startswith('Sorry'): # C++20 unsupported, skip the test + exit(0) + yield _parse_server_start_line(ready_line) + finally: + print('Terminating server...', flush=True) + + # In Windows, there is no sane way to cleanly terminate the process. + # Sending a Ctrl-C terminates all process attached to the console (including ourselves + # and any parent test runner). Running the process in a separate terminal doesn't allow + # access to stdout, which is problematic, too. + if _is_win: + # kill is an alias for TerminateProcess with the given exit code + os.kill(server.pid, 9999) + else: + # Send SIGTERM + server.terminate() + + # Print any output the process generated + print('Server stdout: \n', server.stdout.read().decode(), flush=True) + + # Verify that it exited gracefully + if (_is_win and server.returncode != 9999) or (not _is_win and server.returncode): + raise RuntimeError('Server did not exit cleanly. retcode={}'.format(server.returncode)) + + +class TestOrders(unittest.TestCase): + _port = -1 + + @property + def _base_url(self) -> str: + return 'http://127.0.0.1:{}'.format(self._port) + + @staticmethod + def _json_response(res: requests.Response): + if res.status_code >= 400: + print(res.text) + res.raise_for_status() + return res.json() + + + def _check_error(self, res: requests.Response, expected_status: int) -> None: + self.assertEqual(res.status_code, expected_status) + + + def _request(self, method: str, url: str, **kwargs) -> requests.Response: + return requests.request(method=method, url=self._base_url + url, **kwargs) + + + def _request_as_json(self, method: str, url: str, **kwargs): + return self._json_response(self._request(method, url, **kwargs)) + + + def _request_error(self, method: str, url: str, expected_status: int, **kwargs): + return self._check_error(self._request(method, url, **kwargs), expected_status) + + # + # Success cases + # + def test_search_products(self) -> None: + # Issue the request + products = self._request_as_json('get', '/products', params={'search': 'odin'}) + + # Check + self.assertNotEqual(len(products), 0) # At least one product + odin = products[0] + self.assertIsInstance(odin['id'], int) # We don't know the exact ID + self.assertEqual(odin['short_name'], 'A Feast for Odin') + self.assertEqual(odin['price'], 6400) + self.assertIsInstance(odin['descr'], str) + + + def test_order_lifecycle(self) -> None: + # Create an order + order = self._request_as_json('post', '/orders') + order_id: int = order['id'] + self.assertIsInstance(order_id, int) + self.assertEqual(order['status'], 'draft') + self.assertEqual(order['items'], []) + + # Add an item + order = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + }) + items = order['items'] + self.assertEqual(order['id'], order_id) + self.assertEqual(order['status'], 'draft') + self.assertEqual(len(order['items']), 1) + self.assertIsInstance(items[0]['id'], int) + self.assertEqual(items[0]['product_id'], 2) + self.assertEqual(items[0]['quantity'], 20) + + # Checkout + expected_order = copy.deepcopy(order) + expected_order['status'] = 'pending_payment' + order = self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + self.assertEqual(order, expected_order) + + # Complete + expected_order = copy.deepcopy(order) + expected_order['status'] = 'complete' + order = self._request_as_json('post', '/orders/complete', params={'id': order_id}) + self.assertEqual(order, expected_order) + + + def test_remove_items(self) -> None: + # Create an order + order1 = self._request_as_json('post', '/orders') + order_id: int = order1['id'] + + # Create two items + self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + }) + order2 = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }) + order2_items = order2['items'] + + # Sanity check + self.assertEqual(order2['id'], order_id) + self.assertEqual(order2['status'], 'draft') + self.assertEqual(len(order2_items), 2) + product_ids = list(set(item['product_id'] for item in order2_items)) + self.assertEqual(product_ids, [1, 2]) # IDs 1 and 2 in any order + + # Delete one of the items + order3 = self._request_as_json('delete', '/orders/items', params={'id': order2_items[0]['id']}) + self.assertEqual(order3['id'], order_id) + self.assertEqual(order3['status'], 'draft') + self.assertEqual(order3['items'], [order2_items[1]]) + + + def test_get_orders(self) -> None: + orders = self._request_as_json('get', '/orders') + self.assertIsInstance(orders, list) + + + def test_get_single_order(self) -> None: + # Create an order and add an item + order = self._request_as_json('post', '/orders') + order = self._request_as_json('post', '/orders/items', json={ + 'order_id': order['id'], + 'product_id': 2, + 'quantity': 20 + }) + + # Retrieve the order by id + order2 = self._request_as_json('get', '/orders', params={'id': order['id']}) + self.assertEqual(order2, order) + + + # + # Search products errors + # + def test_search_products_missing_param(self) -> None: + self._request_error('get', '/products', expected_status=400) + + + # + # Get order errors + # + def test_get_order_invalid_id(self) -> None: + self._request_error('get', '/orders', params={'id': 'abc'}, expected_status=400) + + + def test_get_order_not_found(self) -> None: + self._request_error('get', '/orders', params={'id': 0xffffff}, expected_status=404) + + + # + # Add order item errors + # + def test_add_order_item_invalid_content_type(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/items', headers={'Content-Type':'text/html'}, json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }, expected_status=400) + + + def test_add_order_item_invalid_json(self) -> None: + self._request_error('post', '/orders/items', headers={'Content-Type':'application/json'}, + data='bad', expected_status=400) + + + def test_add_order_item_invalid_json_keys(self) -> None: + self._request_error('post', '/orders/items', json={ + 'order_id': '1', + 'product_id': 1, + 'quantity': 1 + }, expected_status=400) + + + def test_add_order_item_order_not_found(self) -> None: + self._request_error('post', '/orders/items', json={ + 'order_id': 0xffffffff, + 'product_id': 1, + 'quantity': 1 + }, expected_status=404) + + + def test_add_order_item_product_not_found(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 0xffffffff, + 'quantity': 1 + }, expected_status=422) + + + def test_add_order_item_order_not_editable(self) -> None: + # Create an order and check it out + order_id = self._request_as_json('post', '/orders')['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }, expected_status=422) + + # + # Remove order item errors + # + def test_remove_order_item_missing_id(self) -> None: + self._request_error('delete', '/orders/items', expected_status=400) + + + def test_remove_order_item_invalid_id(self) -> None: + self._request_error('delete', '/orders/items', params={'id': 'abc'}, expected_status=400) + + + def test_remove_order_item_not_found(self) -> None: + self._request_error('delete', '/orders/items', params={'id': 0xffffffff}, expected_status=404) + + + def test_remove_order_item_order_not_editable(self) -> None: + # Create an order with an item and check it out + order_id = self._request_as_json('post', '/orders')['id'] + item_id = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + })['items'][0]['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('delete', '/orders/items', params={'id': item_id}, expected_status=422) + + + # + # Checkout order errors + # + def test_checkout_order_missing_id(self) -> None: + self._request_error('post', '/orders/checkout', expected_status=400) + + + def test_checkout_order_invalid_id(self) -> None: + self._request_error('post', '/orders/checkout', params={'id': 'abc'}, expected_status=400) + + + def test_checkout_order_not_found(self) -> None: + self._request_error('post', '/orders/checkout', params={'id': 0xffffffff}, expected_status=404) + + + def test_checkout_order_not_editable(self) -> None: + # Create an order and check it out + order_id = self._request_as_json('post', '/orders')['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('post', '/orders/checkout', params={'id': order_id}, expected_status=422) + + + # + # Complete order errors + # + def test_complete_order_missing_id(self) -> None: + self._request_error('post', '/orders/complete', expected_status=400) + + + def test_complete_order_invalid_id(self) -> None: + self._request_error('post', '/orders/complete', params={'id': 'abc'}, expected_status=400) + + + def test_complete_order_not_found(self) -> None: + self._request_error('post', '/orders/complete', params={'id': 0xffffffff}, expected_status=404) + + + def test_complete_order_not_editable(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/complete', params={'id': order_id}, expected_status=422) + + + # + # Generic errors + # + + def test_endpoint_not_found(self) -> None: + self._request_error('get', '/orders/other', expected_status=404) + self._request_error('get', '/orders_other', expected_status=404) + + + def test_method_not_allowed(self) -> None: + self._request_error('delete', '/orders', expected_status=405) + + +def main(): + # Parse command line arguments + parser = argparse.ArgumentParser() + parser.add_argument('executable') + parser.add_argument('host') + args = parser.parse_args() + + # Launch the server + with _launch_server(args.executable, args.host) as listening_port: + TestOrders._port = listening_port + unittest.main(argv=[sys.argv[0]]) + + +if __name__ == '__main__': + main() diff --git a/tools/ci/ci_util/db_setup.py b/tools/ci/ci_util/db_setup.py index 70457bf18..eb794020d 100644 --- a/tools/ci/ci_util/db_setup.py +++ b/tools/ci/ci_util/db_setup.py @@ -92,6 +92,7 @@ def db_setup( # Source files _run_sql_file(source_dir.joinpath('example', 'db_setup.sql')) + _run_sql_file(source_dir.joinpath('example', '3_advanced', 'http_server_cpp20', 'db_setup.sql')) _run_sql_file(source_dir.joinpath('test', 'integration', 'db_setup.sql')) if not disabled_features['sha256']: _run_sql_file(source_dir.joinpath('test', 'integration', 'db_setup_sha256.sql')) diff --git a/tools/scripts/examples_qbk.py b/tools/scripts/examples_qbk.py index c16d4e02b..6f66860b8 100644 --- a/tools/scripts/examples_qbk.py +++ b/tools/scripts/examples_qbk.py @@ -129,6 +129,19 @@ class MultiExample(NamedTuple): ] ADVANCED_EXAMPLES = [ + MultiExample('http_server_cpp20', [ + '3_advanced/http_server_cpp20/main.cpp', + '3_advanced/http_server_cpp20/types.hpp', + '3_advanced/http_server_cpp20/error.hpp', + '3_advanced/http_server_cpp20/error.cpp', + '3_advanced/http_server_cpp20/repository.hpp', + '3_advanced/http_server_cpp20/repository.cpp', + '3_advanced/http_server_cpp20/handle_request.hpp', + '3_advanced/http_server_cpp20/handle_request.cpp', + '3_advanced/http_server_cpp20/server.hpp', + '3_advanced/http_server_cpp20/server.cpp', + ], 'A REST API server that uses C++20 coroutines'), + MultiExample('connection_pool', [ '3_advanced/connection_pool/main.cpp', '3_advanced/connection_pool/types.hpp', @@ -139,7 +152,7 @@ class MultiExample(NamedTuple): '3_advanced/connection_pool/server.hpp', '3_advanced/connection_pool/server.cpp', '3_advanced/connection_pool/log_error.hpp', - ], 'A REST API server that uses connection pooling') + ], 'A REST API server that uses asio::yield_context'), ] ALL_EXAMPLES = TUTORIALS + SIMPLE_EXAMPLES + ADVANCED_EXAMPLES