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 00000000..1429ae39 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -0,0 +1,466 @@ +// +// 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 + +//[example_connection_pool_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 "handle_request.hpp" +#include "log_error.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; + +namespace { + +// Attempts to parse a numeric ID from a string +static 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; +} + +// Encapsulates the logic required to match a HTTP request +// to an API endpoint, call the relevant note_repository function, +// and return an HTTP response. +class request_handler +{ +public: + // The HTTP request we're handling. Requests are small in size, + // so we use http::request + const http::request& request_; + boost::urls::url_view target; + + // The repository to access MySQL + orders::db_repository repo_; + + // Creates an error response + http::response error_response(http::status code, std::string_view msg) const + { + http::response res; + res.result(code); + res.body() = msg; + return res; + } + + // TODO + http::response bad_request(std::string body) const + { + return error_response(http::status::bad_request, std::move(body)); + } + + // Used when the request's Content-Type header doesn't match what we expect + http::response invalid_content_type() const + { + return error_response(http::status::bad_request, "Invalid content-type"); + } + + // Used when the request body didn't match the format we expect + http::response invalid_body() const + { + return error_response(http::status::bad_request, "Invalid body"); + } + + // Used when the request's method didn't match the ones allowed by the endpoint + http::response method_not_allowed() const + { + return error_response(http::status::method_not_allowed, "Method not allowed"); + } + + // 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") + const + { + return error_response(http::status::not_found, std::move(body)); + } + + // 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) const + { + http::response res; + + // Set the content-type header + res.set("Content-Type", "application/json"); + + // Set the keep-alive option + res.keep_alive(request_.keep_alive()); + + // 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)); + + // Adjust the content-length header + res.prepare_payload(); + + // Done + return res; + } + + // Returns true if the request's Content-Type is set to JSON + bool has_json_content_type() const + { + auto it = request_.find("Content-Type"); + return it != request_.end() && it->value() == "application/json"; + } + + // Attempts to parse the request body 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 + boost::system::result parse_json_request() const + { + boost::system::error_code ec; + + // Attempt to parse the request into a json::value. + // This will fail if the provided body isn't valid JSON. + auto val = boost::json::parse(request_.body(), 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); + } + + // asio::awaitable> handle_request_impl() + // { + // // Parse the request target. We use Boost.Url to do this. + // auto url = boost::urls::parse_origin_form(request_.target()); + // if (url.has_error()) + // co_return error_response(http::status::bad_request, "Invalid request target"); + + // // We will be iterating over the target's segments to determine + // // which endpoint we are being requested + // auto url_params = url->params(); + // auto segs = url->segments(); + // auto segit = segs.begin(); + // auto seg = *segit++; + + // // Endpoints starting with /products + // if (seg == "products" && segit == segs.end()) + // { + // if (request_.method() == http::verb::get) + // { + // // Invoke the database logic + // // vector (string search) + // } + // else + // { + // co_return method_not_allowed(); + // } + // } + // // Endpoints starting with /orders + // else if (seg == "orders") + // { + // if (segit == segs.end()) + // { + // if (request_.method() ==) + // } + // } + + // // All endpoints start with /notes + // if (seg != "notes") + // co_return endpoint_not_found(); + + // if (segit == segs.end()) + // { + // if (request_.method() == http::verb::get) + // { + // // GET /notes: retrieves all the notes. + // // The request doesn't have a body. + // // The response has a JSON body with multi_notes_response format + // auto res = repo_.get_notes(yield); + // return json_response(multi_notes_response{std::move(res)}); + // } + // else if (request_.method() == http::verb::post) + // { + // // POST /notes: creates a note. + // // The request has a JSON body with note_request_body format. + // // The response has a JSON body with single_note_response format. + + // // Parse the request body + // if (!has_json_content_type()) + // return invalid_content_type(); + // auto args = parse_json_request(); + // if (args.has_error()) + // return invalid_body(); + + // // Actually create the note + // auto res = repo_.create_note(args->title, args->content, yield); + + // // Return the newly crated note as response + // return json_response(single_note_response{std::move(res)}); + // } + // else + // { + // return method_not_allowed(); + // } + // } + // else + // { + // // The URL has the form /notes/. Parse the note ID. + // auto note_id = parse_id(*segit++); + // if (!note_id.has_value()) + // { + // return error_response( + // http::status::bad_request, + // "Invalid note_id specified in request target" + // ); + // } + + // // /notes// is not a valid endpoint + // if (segit != segs.end()) + // return endpoint_not_found(); + + // if (request_.method() == http::verb::get) + // { + // // GET /notes/: retrieves a single note. + // // The request doesn't have a body. + // // The response has a JSON body with single_note_response format + + // // Get the note + // auto res = repo_.get_note(*note_id, yield); + + // // If we didn't find it, return a 404 error + // if (!res.has_value()) + // return note_not_found(); + + // // Return it as response + // return json_response(single_note_response{std::move(*res)}); + // } + // else if (request_.method() == http::verb::put) + // { + // // PUT /notes/: replaces a note. + // // The request has a JSON body with note_request_body format. + // // The response has a JSON body with single_note_response format. + + // // Parse the JSON body + // if (!has_json_content_type()) + // return invalid_content_type(); + // auto args = parse_json_request(); + // if (args.has_error()) + // return invalid_body(); + + // // Perform the update + // auto res = repo_.replace_note(*note_id, args->title, args->content, yield); + + // // Check that it took effect. Otherwise, it's because the note wasn't there + // if (!res.has_value()) + // return note_not_found(); + + // // Return the updated note as response + // return json_response(single_note_response{std::move(*res)}); + // } + // else if (request_.method() == http::verb::delete_) + // { + // // DELETE /notes/: deletes a note. + // // The request doesn't have a body. + // // The response has a JSON body with delete_note_response format. + + // // Attempt to delete the note + // bool deleted = repo_.delete_note(*note_id, yield); + + // // Return whether the delete was successful in the response. + // // We don't fail DELETEs for notes that don't exist. + // return json_response(delete_note_response{deleted}); + // } + // else + // { + // return method_not_allowed(); + // } + // } + // } + + // Constructor + request_handler(const http::request& req, mysql::connection_pool& pool) noexcept + : request_(req), repo_(pool) + { + } +}; + +// GET /products: search for available products +asio::awaitable> handle_get_products(request_handler& handler) +{ + // Parse the query parameter + auto params_it = handler.target.params().find("search"); + if (params_it == handler.target.params().end()) + co_return handler.bad_request("Missing mandatory query parameter: 'search'"); + auto search = (*params_it).value; + + // Invoke the database logic + std::vector products = co_await handler.repo_.get_products(search); + + // Return the response + co_return handler.json_response(products); +} + +asio::awaitable> handle_get_orders(request_handler& handler) +{ + // Parse the query parameter + auto params_it = handler.target.params().find("id"); + + if (params_it == handler.target.params().end()) + { + // If the query parameter is not present, return all orders + // Invoke the database logic + std::vector orders = co_await handler.repo_.get_orders(); + + // Return the response + co_return handler.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 handler.bad_request("id should be a valid integer"); + + // Invoke the database logic + std::optional order = co_await handler.repo_.get_order_by_id(*order_id); + + // Return the response + if (!order.has_value()) + co_return handler.not_found("Order not found"); + co_return handler.json_response(*order); + } +} + +asio::awaitable> handle_create_order(request_handler& handler) +{ + // Invoke the database logic + orders::order_with_items order = co_await handler.repo_.create_order(); + + // Return the response + co_return handler.json_response(order); +} + +asio::awaitable> handle_add_item(request_handler& handler) +{ + // TODO +} + +struct http_endpoint +{ + http::verb method; + asio::awaitable> (*handler)(request_handler&); +}; + +const std::unordered_multimap endpoint_map{ + {"/products", {http::verb::get, &handle_get_products} }, + {"/orders", {http::verb::get, &handle_get_orders} }, + {"/orders", {http::verb::post, &handle_create_order}} +}; + +} // namespace + +// External interface +asio::awaitable> orders::handle_request( + const http::request& request, + mysql::connection_pool& pool +) +{ + request_handler handler(pool); + + // Try to find an endpoint + auto it = endpoint_map.find(handler.target.path()); + if (it == endpoint_map.end()) + { + co_return handler.endpoint_not_found(); + } + + // Match the verb + auto it2 = std::find_if( + + ) + + try + { + // Attempt to handle the request. We use cancel_after to set + // a timeout to the overall operation + return asio::spawn( + yield.get_executor(), + [this](asio::yield_context yield2) { return handle_request_impl(yield2); }, + asio::cancel_after(std::chrono::seconds(30), yield) + ); + } + catch (const boost::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, and return a generic 500 + log_error( + "Uncaught exception: ", + err.what(), + "\nServer diagnostics: ", + err.get_diagnostics().server_message() + ); + return error_response(http::status::internal_server_error, "Internal 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. + log_error("Uncaught exception: ", err.what()); + return error_response(http::status::internal_server_error, "Internal 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 00000000..fd71152c --- /dev/null +++ b/example/3_advanced/http_server_cpp20/handle_request.hpp @@ -0,0 +1,36 @@ +// +// 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_CONNECTION_POOL_HANDLE_REQUEST_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_CONNECTION_POOL_HANDLE_REQUEST_HPP + +//[example_connection_pool_handle_request_hpp +// +// File: handle_request.hpp +// + +#include + +#include +#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/log_error.hpp b/example/3_advanced/http_server_cpp20/log_error.hpp new file mode 100644 index 00000000..a869d1ae --- /dev/null +++ b/example/3_advanced/http_server_cpp20/log_error.hpp @@ -0,0 +1,42 @@ +// +// 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_CONNECTION_POOL_LOG_ERROR_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_CONNECTION_POOL_LOG_ERROR_HPP + +//[example_connection_pool_log_error_hpp +// +// File: log_error.hpp +// + +#include +#include + +// Helper function to safely write diagnostics to std::cerr. +// Since we're in a multi-threaded environment, directly writing to std::cerr +// can lead to interleaved output, so we should synchronize calls with a mutex. +// This function is only called in rare cases (e.g. unhandled exceptions), +// so we can afford the synchronization overhead. + +namespace orders { + +// TODO: is there a better way? +template +void log_error(const Args&... args) +{ + static std::mutex mtx; + + // Acquire the mutex, then write the passed arguments to std::cerr. + std::unique_lock lock(mtx); + std::cerr << (... << args) << std::endl; +} + +} // 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 00000000..9894d6ed --- /dev/null +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -0,0 +1,175 @@ +// +// 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?order-id={} 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_examples", + + // 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 00000000..55cea0ab --- /dev/null +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -0,0 +1,381 @@ +// +// 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 +#if defined(BOOST_ASIO_HAS_CO_AWAIT) + +//[example_connection_pool_repository_cpp +// +// File: repository.cpp +// + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "repository.hpp" +#include "types.hpp" + +namespace mysql = boost::mysql; +namespace asio = boost::asio; +using namespace orders; + +/** Database 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) +); + +*/ + +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 std::nullopt; + 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. + co_return result.rows<2>().front(); +} + +// TODO: we should probably use system::result to communicate what happened +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 std::nullopt; + } + 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 std::nullopt; + } + + // Check that the product exists + if (result1.rows<2>().empty()) + { + co_return std::nullopt; + } + + // 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(); + + // Delete the item and retrieve the updated order. + // The DELETE checks that the order exists and is editable. + mysql::static_results, std::tuple<>, order, order_item, std::tuple<>> result; + co_await conn->async_execute( + mysql::with_params( + "START TRANSACTION;" + "DELETE it FROM order_items it" + " JOIN orders ord ON (it.order_id = ord.id)" + " WHERE it.id = {0} AND ord.status = 'draft';" + "SELECT ord.id AS id, status FROM orders ord" + " JOIN order_items it ON (it.order_id = ord.id)" + " WHERE it.id = {0};" + "SELECT id, product_id, quantity FROM order_items" + " WHERE order_id = (SELECT order_id FROM order_items WHERE id = {0});" + "COMMIT", + item_id + ), + result + ); + + // We didn't mutate session state + conn.return_without_reset(); + + // Check that the order exists + if (result.rows<2>().empty()) + { + // Not found. We did mutate session state by opening a transaction, + // so we can't use return_without_reset + co_return std::nullopt; + } + const order& ord = result.rows<2>().front(); + + // Check that the item was deleted + if (result.affected_rows<1>() == 0u) + { + // Nothing was deleted + co_return std::nullopt; + } + + // Compose the return value + co_return order_with_items{ + ord.id, + ord.status, + {result.rows<3>().begin(), result.rows<3>().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 std::nullopt; + } + + // Check that the order is in the expected status + if (std::get<0>(result1.rows<1>().front()) != original_status) + { + co_return std::nullopt; + } + + // 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 00000000..60d3a9d5 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/repository.hpp @@ -0,0 +1,62 @@ +// +// 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_CONNECTION_POOL_REPOSITORY_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_CONNECTION_POOL_REPOSITORY_HPP + +//[example_connection_pool_repository_hpp +// +// File: repository.hpp +// + +#include + +#include + +#include +#include +#include +#include + +#include "types.hpp" + +namespace orders { + +// A lightweight wrapper around a connection_pool that allows +// creating, updating, retrieving and deleting notes in MySQL. +// This class encapsulates the database logic. +// All operations are async, and use stackful coroutines (boost::asio::yield_context). +// If the database can't be contacted, or unexpected database errors are found, +// an exception of type boost::mysql::error_with_diagnostics is thrown. +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 all notes present in the database + boost::asio::awaitable> get_products(std::string_view search); + boost::asio::awaitable> get_orders(); + boost::asio::awaitable> get_order_by_id(std::int64_t id); + boost::asio::awaitable create_order(); + boost::asio::awaitable> add_order_item( + std::int64_t order_id, + std::int64_t product_id, + std::int64_t quantity + ); + boost::asio::awaitable> remove_order_item(std::int64_t item_id); + boost::asio::awaitable> checkout_order(std::int64_t id); + 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 00000000..4e4f672a --- /dev/null +++ b/example/3_advanced/http_server_cpp20/server.cpp @@ -0,0 +1,215 @@ +// +// 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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED + +//[example_connection_pool_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 "handle_request.hpp" +#include "server.hpp" +// #include "types.hpp" +#include "log_error.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 00000000..74c93140 --- /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_CONNECTION_POOL_SERVER_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_CONNECTION_POOL_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 00000000..398d735e --- /dev/null +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -0,0 +1,87 @@ +// +// 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_CONNECTION_POOL_TYPES_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_CONNECTION_POOL_TYPES_HPP + +//[example_http_cpp20_types_hpp +// +// File: types.hpp +// + +#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::string 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. +// + +// +// REST API responses. +// + +} // namespace orders + +//] + +#endif diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index 681844e2..94fa798d 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -113,3 +113,19 @@ add_example( PYTHON_RUNNER run_connection_pool.py ARGS ${SERVER_HOST} ) + +add_example( + http_server_cpp20 + SOURCES + 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_connection_pool.py # TODO + ARGS ${SERVER_HOST} +) diff --git a/example/db_setup.sql b/example/db_setup.sql index fade08cc..2becb7ad 100644 --- a/example/db_setup.sql +++ b/example/db_setup.sql @@ -88,7 +88,9 @@ GRANT ALL PRIVILEGES ON boost_mysql_examples.* TO 'example_user'@'%'; FLUSH PRIVILEGES; --- Tables for the orders examples +-- +-- Orders examples +-- CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, short_name VARCHAR(100) NOT NULL,