From 57cb218b119a5f842fe2462b46a9bd668b4a0b2c Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 1 Dec 2024 19:13:01 +0100 Subject: [PATCH 1/5] Initial prototype --- .../http_server_cpp20/handle_request.cpp | 466 ++++++++++++++++++ .../http_server_cpp20/handle_request.hpp | 36 ++ .../http_server_cpp20/log_error.hpp | 42 ++ example/3_advanced/http_server_cpp20/main.cpp | 175 +++++++ .../http_server_cpp20/repository.cpp | 193 ++++++++ .../http_server_cpp20/repository.hpp | 62 +++ .../3_advanced/http_server_cpp20/server.cpp | 215 ++++++++ .../3_advanced/http_server_cpp20/server.hpp | 34 ++ .../3_advanced/http_server_cpp20/types.hpp | 87 ++++ example/CMakeLists.txt | 16 + example/db_setup.sql | 4 +- 11 files changed, 1329 insertions(+), 1 deletion(-) create mode 100644 example/3_advanced/http_server_cpp20/handle_request.cpp create mode 100644 example/3_advanced/http_server_cpp20/handle_request.hpp create mode 100644 example/3_advanced/http_server_cpp20/log_error.hpp create mode 100644 example/3_advanced/http_server_cpp20/main.cpp create mode 100644 example/3_advanced/http_server_cpp20/repository.cpp create mode 100644 example/3_advanced/http_server_cpp20/repository.hpp create mode 100644 example/3_advanced/http_server_cpp20/server.cpp create mode 100644 example/3_advanced/http_server_cpp20/server.hpp create mode 100644 example/3_advanced/http_server_cpp20/types.hpp 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..1429ae39d --- /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 000000000..fd71152c0 --- /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 000000000..a869d1ae2 --- /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 000000000..9894d6ed0 --- /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 000000000..c7fc9179f --- /dev/null +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -0,0 +1,193 @@ +// +// 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 + +#ifdef BOOST_MYSQL_CXX14 + +//[example_connection_pool_repository_cpp +// +// File: repository.cpp +// + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "repository.hpp" +#include "types.hpp" + +using namespace notes; +namespace mysql = boost::mysql; +using mysql::with_diagnostics; + +// SQL code to create the notes table is located under $REPO_ROOT/example/db_setup.sql +// The table looks like this: +// +// CREATE TABLE notes( +// id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, +// title TEXT NOT NULL, +// content TEXT NOT NULL +// ); + +std::vector note_repository::get_notes(boost::asio::yield_context yield) +{ + // Get a fresh connection from the pool. This returns a pooled_connection object, + // which is a proxy to an any_connection object. Connections are returned to the + // pool when the proxy object is destroyed. + // with_diagnostics ensures that thrown exceptions include diagnostic information + mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); + + // Execute the query to retrieve all notes. We use the static interface to + // parse results directly into static_results. + mysql::static_results result; + conn->async_execute("SELECT id, title, content FROM notes", result, with_diagnostics(yield)); + + // 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. + conn.return_without_reset(); + + // Move note_t objects into the result vector to save allocations + return std::vector( + std::make_move_iterator(result.rows().begin()), + std::make_move_iterator(result.rows().end()) + ); + + // If an exception is thrown, pooled_connection's destructor will + // return the connection automatically to the pool. +} + +optional note_repository::get_note(std::int64_t note_id, boost::asio::yield_context yield) +{ + // Get a fresh connection from the pool. This returns a pooled_connection object, + // which is a proxy to an any_connection object. Connections are returned to the + // pool when the proxy object is destroyed. + mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); + + // When executed, with_params expands a query client-side before sending it to the server. + // Placeholders are marked with {} + mysql::static_results result; + conn->async_execute( + mysql::with_params("SELECT id, title, content FROM notes WHERE id = {}", note_id), + result, + with_diagnostics(yield) + ); + + // We did nothing to mutate session state, so we can skip reset + conn.return_without_reset(); + + // An empty results object indicates that no note was found + if (result.rows().empty()) + return {}; + else + return std::move(result.rows()[0]); +} + +note_t note_repository::create_note(string_view title, string_view content, boost::asio::yield_context yield) +{ + // Get a fresh connection from the pool. This returns a pooled_connection object, + // which is a proxy to an any_connection object. Connections are returned to the + // pool when the proxy object is destroyed. + mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); + + // We will use statements in this function for the sake of example. + // We don't need to deallocate the statement explicitly, + // since the pool takes care of it after the connection is returned. + // You can also use with_params instead of statements. + mysql::statement stmt = conn->async_prepare_statement( + "INSERT INTO notes (title, content) VALUES (?, ?)", + with_diagnostics(yield) + ); + + // Execute the statement. The statement won't produce any rows, + // so we can use static_results> + mysql::static_results> result; + conn->async_execute(stmt.bind(title, content), result, with_diagnostics(yield)); + + // MySQL reports last_insert_id as a uint64_t regardless of the actual ID type. + // Given our table definition, this cast is safe + auto new_id = static_cast(result.last_insert_id()); + + return note_t{new_id, title, content}; + + // There's no need to return the connection explicitly to the pool, + // pooled_connection's destructor takes care of it. +} + +optional note_repository::replace_note( + std::int64_t note_id, + string_view title, + string_view content, + boost::asio::yield_context yield +) +{ + // Get a fresh connection from the pool. This returns a pooled_connection object, + // which is a proxy to an any_connection object. Connections are returned to the + // pool when the proxy object is destroyed. + mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); + + // Expand and execute the query. + // It won't produce any rows, so we can use static_results> + mysql::static_results> empty_result; + conn->async_execute( + mysql::with_params( + "UPDATE notes SET title = {}, content = {} WHERE id = {}", + title, + content, + note_id + ), + empty_result, + with_diagnostics(yield) + ); + + // We didn't mutate session state, so we can skip reset + conn.return_without_reset(); + + // No affected rows means that the note doesn't exist + if (empty_result.affected_rows() == 0u) + return {}; + + return note_t{note_id, title, content}; +} + +bool note_repository::delete_note(std::int64_t note_id, boost::asio::yield_context yield) +{ + // Get a fresh connection from the pool. This returns a pooled_connection object, + // which is a proxy to an any_connection object. Connections are returned to the + // pool when the proxy object is destroyed. + mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); + + // Expand and execute the query. + // It won't produce any rows, so we can use static_results> + mysql::static_results> empty_result; + conn->async_execute( + mysql::with_params("DELETE FROM notes WHERE id = {}", note_id), + empty_result, + with_diagnostics(yield) + ); + + // We didn't mutate session state, so we can skip reset + conn.return_without_reset(); + + // No affected rows means that the note didn't exist + return empty_result.affected_rows() != 0u; +} + +//] + +#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..60d3a9d5c --- /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 000000000..4e4f672a3 --- /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 000000000..74c931407 --- /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 000000000..398d735eb --- /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 681844e28..94fa798da 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 fade08ccd..2becb7ad8 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, From 96c7e46ea16c2763d23d86354c5a1a637b6099c2 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Fri, 6 Dec 2024 20:27:12 +0100 Subject: [PATCH 2/5] Repository (1) --- .../http_server_cpp20/repository.cpp | 243 +++++++++++++++++- 1 file changed, 229 insertions(+), 14 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index c7fc9179f..337febc4d 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -5,40 +5,255 @@ // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) // -#include - -#ifdef BOOST_MYSQL_CXX14 +#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" -using namespace notes; namespace mysql = boost::mysql; -using mysql::with_diagnostics; +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 + ); -// SQL code to create the notes table is located under $REPO_ROOT/example/db_setup.sql -// The table looks like this: -// -// CREATE TABLE notes( -// id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, -// title TEXT NOT NULL, -// content TEXT NOT NULL -// ); + // 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 order WHERE id = {} FOR SHARE;" + "SELECT id FROM product 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 + ); + + // 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) {} + +asio::awaitable> db_repository::checkout_order(std::int64_t id) {} + +asio::awaitable> db_repository::complete_order(std::int64_t id) {} std::vector note_repository::get_notes(boost::asio::yield_context yield) { From d93bf07ac74cbb58ab6364bf7e4b81d7fc887438 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Fri, 6 Dec 2024 21:22:23 +0100 Subject: [PATCH 3/5] repository (2) --- .../http_server_cpp20/repository.cpp | 218 +++++++----------- 1 file changed, 81 insertions(+), 137 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 337febc4d..964dd0999 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -23,8 +23,8 @@ #include #include +#include #include -#include #include "repository.hpp" #include "types.hpp" @@ -197,8 +197,8 @@ asio::awaitable> db_repository::add_order_item( co_await conn->async_execute( mysql::with_params( "START TRANSACTION;" - "SELECT id, status FROM order WHERE id = {} FOR SHARE;" - "SELECT id FROM product WHERE id = {} FOR SHARE", + "SELECT id, status FROM orders WHERE id = {} FOR SHARE;" + "SELECT id FROM products WHERE id = {} FOR SHARE", order_id, product_id ), @@ -249,160 +249,104 @@ asio::awaitable> db_repository::add_order_item( }; } -asio::awaitable> db_repository::remove_order_item(std::int64_t item_id) {} - -asio::awaitable> db_repository::checkout_order(std::int64_t id) {} - -asio::awaitable> db_repository::complete_order(std::int64_t id) {} - -std::vector note_repository::get_notes(boost::asio::yield_context yield) +asio::awaitable> db_repository::remove_order_item(std::int64_t item_id) { - // Get a fresh connection from the pool. This returns a pooled_connection object, - // which is a proxy to an any_connection object. Connections are returned to the - // pool when the proxy object is destroyed. - // with_diagnostics ensures that thrown exceptions include diagnostic information - mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); - - // Execute the query to retrieve all notes. We use the static interface to - // parse results directly into static_results. - mysql::static_results result; - conn->async_execute("SELECT id, title, content FROM notes", result, with_diagnostics(yield)); - - // 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. - conn.return_without_reset(); + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); - // Move note_t objects into the result vector to save allocations - return std::vector( - std::make_move_iterator(result.rows().begin()), - std::make_move_iterator(result.rows().end()) + // 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 ); - // If an exception is thrown, pooled_connection's destructor will - // return the connection automatically to the pool. -} - -optional note_repository::get_note(std::int64_t note_id, boost::asio::yield_context yield) -{ - // Get a fresh connection from the pool. This returns a pooled_connection object, - // which is a proxy to an any_connection object. Connections are returned to the - // pool when the proxy object is destroyed. - mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); - - // When executed, with_params expands a query client-side before sending it to the server. - // Placeholders are marked with {} - mysql::static_results result; - conn->async_execute( - mysql::with_params("SELECT id, title, content FROM notes WHERE id = {}", note_id), - result, - with_diagnostics(yield) - ); + // 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(); - // We did nothing to mutate session state, so we can skip reset - conn.return_without_reset(); + // Check that the item was deleted + if (result.affected_rows<1>() == 0u) + { + // Nothing was deleted + co_return std::nullopt; + } - // An empty results object indicates that no note was found - if (result.rows().empty()) - return {}; - else - return std::move(result.rows()[0]); + // Compose the return value + co_return order_with_items{ + ord.id, + ord.status, + {result.rows<3>().begin(), result.rows<3>().end()} + }; } -note_t note_repository::create_note(string_view title, string_view content, boost::asio::yield_context yield) +asio::awaitable> db_repository::checkout_order(std::int64_t id) { - // Get a fresh connection from the pool. This returns a pooled_connection object, - // which is a proxy to an any_connection object. Connections are returned to the - // pool when the proxy object is destroyed. - mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); - - // We will use statements in this function for the sake of example. - // We don't need to deallocate the statement explicitly, - // since the pool takes care of it after the connection is returned. - // You can also use with_params instead of statements. - mysql::statement stmt = conn->async_prepare_statement( - "INSERT INTO notes (title, content) VALUES (?, ?)", - with_diagnostics(yield) - ); - - // Execute the statement. The statement won't produce any rows, - // so we can use static_results> - mysql::static_results> result; - conn->async_execute(stmt.bind(title, content), result, with_diagnostics(yield)); - - // MySQL reports last_insert_id as a uint64_t regardless of the actual ID type. - // Given our table definition, this cast is safe - auto new_id = static_cast(result.last_insert_id()); - - return note_t{new_id, title, content}; - - // There's no need to return the connection explicitly to the pool, - // pooled_connection's destructor takes care of it. -} + // Get a connection from the pool + auto conn = co_await pool_.async_get_connection(); -optional note_repository::replace_note( - std::int64_t note_id, - string_view title, - string_view content, - boost::asio::yield_context yield -) -{ - // Get a fresh connection from the pool. This returns a pooled_connection object, - // which is a proxy to an any_connection object. Connections are returned to the - // pool when the proxy object is destroyed. - mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); - - // Expand and execute the query. - // It won't produce any rows, so we can use static_results> - mysql::static_results> empty_result; - conn->async_execute( + mysql::static_results, std::tuple> result1; + co_await conn->async_execute( mysql::with_params( - "UPDATE notes SET title = {}, content = {} WHERE id = {}", - title, - content, - note_id + "START TRANSACTION;" + "SELECT status FROM orders WHERE id = {} FOR UPDATE;", + id ), - empty_result, - with_diagnostics(yield) + result1 ); - // We didn't mutate session state, so we can skip reset - conn.return_without_reset(); - - // No affected rows means that the note doesn't exist - if (empty_result.affected_rows() == 0u) - return {}; + // Check that the order exists + if (result1.rows<1>().empty()) + { + co_return std::nullopt; + } - return note_t{note_id, title, content}; -} + // Check that the order is in the expected status + if (std::get<0>(result1.rows<1>().front()) != status_draft) + { + co_return std::nullopt; + } -bool note_repository::delete_note(std::int64_t note_id, boost::asio::yield_context yield) -{ - // Get a fresh connection from the pool. This returns a pooled_connection object, - // which is a proxy to an any_connection object. Connections are returned to the - // pool when the proxy object is destroyed. - mysql::pooled_connection conn = pool_.async_get_connection(with_diagnostics(yield)); - - // Expand and execute the query. - // It won't produce any rows, so we can use static_results> - mysql::static_results> empty_result; - conn->async_execute( - mysql::with_params("DELETE FROM notes WHERE id = {}", note_id), - empty_result, - with_diagnostics(yield) + // + mysql::static_results, order_item, std::tuple<>> result2; + co_await conn->async_execute( + mysql::with_params( + "UPDATE orders SET status = 'pending_payment' WHERE id = {0};" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {0};" + "COMMIT", + id + ), + result2 ); - // We didn't mutate session state, so we can skip reset - conn.return_without_reset(); - - // No affected rows means that the note didn't exist - return empty_result.affected_rows() != 0u; + // Compose the return value + co_return order_with_items{ + id, + std::string(status_pending_payment), + {result2.rows<1>().begin(), result2.rows<1>().end()} + }; } +asio::awaitable> db_repository::complete_order(std::int64_t id) {} + //] #endif From 62b27e2bd88b7f9c3265c63b14e812f081e289a3 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Fri, 6 Dec 2024 21:27:08 +0100 Subject: [PATCH 4/5] Finished repository --- .../http_server_cpp20/repository.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 964dd0999..50edf2076 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include "repository.hpp" @@ -298,17 +299,27 @@ asio::awaitable> db_repository::remove_order_ite }; } -asio::awaitable> db_repository::checkout_order(std::int64_t id) +// 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(); + 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;", - id + order_id ), result1 ); @@ -320,32 +331,41 @@ asio::awaitable> db_repository::checkout_order(s } // Check that the order is in the expected status - if (std::get<0>(result1.rows<1>().front()) != status_draft) + 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 = 'pending_payment' WHERE id = {0};" + "UPDATE orders SET status = {1} WHERE id = {0};" "SELECT id, product_id, quantity FROM order_items WHERE order_id = {0};" "COMMIT", - id + order_id, + target_status ), result2 ); // Compose the return value co_return order_with_items{ - id, - std::string(status_pending_payment), + order_id, + std::string(target_status), {result2.rows<1>().begin(), result2.rows<1>().end()} }; } -asio::awaitable> db_repository::complete_order(std::int64_t id) {} +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); +} //] From 91b646d9aad61784b41c3821546338c6f9c2d965 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Fri, 6 Dec 2024 21:28:21 +0100 Subject: [PATCH 5/5] return without reset --- example/3_advanced/http_server_cpp20/repository.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 50edf2076..55cea0ab6 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -242,6 +242,9 @@ asio::awaitable> db_repository::add_order_item( 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, @@ -275,6 +278,9 @@ asio::awaitable> db_repository::remove_order_ite result ); + // We didn't mutate session state + conn.return_without_reset(); + // Check that the order exists if (result.rows<2>().empty()) { @@ -349,6 +355,9 @@ static asio::awaitable> change_order_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,