From 57cb218b119a5f842fe2462b46a9bd668b4a0b2c Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 1 Dec 2024 19:13:01 +0100 Subject: [PATCH 01/46] 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 02/46] 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 03/46] 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 04/46] 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 05/46] 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, From 512c1321dfe450f182ad39cbedc7b6bf9276b8e5 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 9 Dec 2024 13:28:53 +0100 Subject: [PATCH 06/46] Remove PFR include and check --- example/3_advanced/http_server_cpp20/main.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp index 9894d6ed0..776a1d441 100644 --- a/example/3_advanced/http_server_cpp20/main.cpp +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -5,10 +5,8 @@ // 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 +#if defined(BOOST_ASIO_HAS_CO_AWAIT) //[example_http_server_cpp20_main_cpp From fe096c2d75e04bbc3dfb56634bf59d2ea050ddd3 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Fri, 13 Dec 2024 19:14:50 +0100 Subject: [PATCH 07/46] error.hpp/cpp --- .../3_advanced/http_server_cpp20/error.cpp | 49 +++++++++++++++++++ .../3_advanced/http_server_cpp20/error.hpp | 46 +++++++++++++++++ example/CMakeLists.txt | 1 + 3 files changed, 96 insertions(+) create mode 100644 example/3_advanced/http_server_cpp20/error.cpp create mode 100644 example/3_advanced/http_server_cpp20/error.hpp diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp new file mode 100644 index 000000000..4e424dfd2 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -0,0 +1,49 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +//[example_http_server_cpp20_error_cpp + +#include + +#include "error.hpp" + +namespace { + +// Converts an orders::errc to string +const char* error_to_string(orders::errc value) +{ + switch (value) + { + case orders::errc::not_found: return "not_found"; + case orders::errc::order_not_editable: return "order_not_editable"; + case orders::errc::order_not_pending_payment: return "order_not_pending_payment"; + case orders::errc::product_not_found: return "product_not_found"; + default: return ""; + } +} + +// Custom category for orders::errc +class orders_category final : public boost::system::error_category +{ +public: + // Identifies the error category. Used when converting error_codes to string + const char* name() const noexcept final override { return "orders"; } + + // Given a numeric error belonging to this category, convert it to a string + std::string message(int ev) const final override + { + return error_to_string(static_cast(ev)); + } +}; + +static const orders_category cat; + +} // namespace + +const boost::system::error_category& orders::get_orders_category() { return cat; } + +//] \ No newline at end of file diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp new file mode 100644 index 000000000..aec0e3886 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -0,0 +1,46 @@ +// +// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// + +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_ERROR_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_ERROR_HPP + +//[example_http_server_cpp20_error_hpp +// +// File: error.hpp +// +// Contains an errc enumeration and the required pieces to +// use it with Boost.System error codes. +// We use this indirectly in the DB repository class, +// when using the error codes in boost::system::result. + +#include + +namespace orders { + +// Error code enum for errors originated within our application +enum class errc +{ + not_found, // couldn't retrieve or modify a certain resource because it doesn't exist + order_not_editable, // an operation requires an order to be editable, but it's not + order_not_pending_payment, // an operation requires an order to be pending payment, but it's not + product_not_found, // a product referenced by a request doesn't exist +}; + +// The error category for errc +const boost::system::error_category& get_orders_category(); + +// Allows constructing error_code from errc +inline boost::system::error_code make_error_code(errc v) +{ + return boost::system::error_code(static_cast(v), get_orders_category()); +} + +} // namespace orders + +//] + +#endif diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index 94fa798da..ba5cbf137 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -117,6 +117,7 @@ add_example( add_example( http_server_cpp20 SOURCES + 3_advanced/http_server_cpp20/error.cpp 3_advanced/http_server_cpp20/repository.cpp 3_advanced/http_server_cpp20/handle_request.cpp 3_advanced/http_server_cpp20/server.cpp From 2c6e4be8000743411b112302f0f609ed2769c5cb Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 16:15:38 +0100 Subject: [PATCH 08/46] Update log_error includes --- example/3_advanced/http_server_cpp20/log_error.hpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/log_error.hpp b/example/3_advanced/http_server_cpp20/log_error.hpp index a869d1ae2..acd9d17a5 100644 --- a/example/3_advanced/http_server_cpp20/log_error.hpp +++ b/example/3_advanced/http_server_cpp20/log_error.hpp @@ -5,23 +5,22 @@ // 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 +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_LOG_ERROR_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_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. +#include +#include + namespace orders { // TODO: is there a better way? From 43e3e90f4cd8c3c7270770671ba752bb6ada51f2 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 16:15:53 +0100 Subject: [PATCH 09/46] Other include guards --- example/3_advanced/http_server_cpp20/handle_request.hpp | 4 ++-- example/3_advanced/http_server_cpp20/server.hpp | 4 ++-- example/3_advanced/http_server_cpp20/types.hpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.hpp b/example/3_advanced/http_server_cpp20/handle_request.hpp index fd71152c0..5add3e3b0 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.hpp +++ b/example/3_advanced/http_server_cpp20/handle_request.hpp @@ -5,8 +5,8 @@ // 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 +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP //[example_connection_pool_handle_request_hpp // diff --git a/example/3_advanced/http_server_cpp20/server.hpp b/example/3_advanced/http_server_cpp20/server.hpp index 74c931407..579027856 100644 --- a/example/3_advanced/http_server_cpp20/server.hpp +++ b/example/3_advanced/http_server_cpp20/server.hpp @@ -5,8 +5,8 @@ // 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 +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_SERVER_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_SERVER_HPP //[example_http_server_cpp20_server_hpp // diff --git a/example/3_advanced/http_server_cpp20/types.hpp b/example/3_advanced/http_server_cpp20/types.hpp index 398d735eb..1bcefb2d0 100644 --- a/example/3_advanced/http_server_cpp20/types.hpp +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -5,8 +5,8 @@ // 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 +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP //[example_http_cpp20_types_hpp // From 3b6c2d0799343546539d4ac13217ec5e4d46b8d6 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 16:52:59 +0100 Subject: [PATCH 10/46] Handler bodies --- .../3_advanced/http_server_cpp20/error.hpp | 15 + .../http_server_cpp20/handle_request.cpp | 294 ++++++++---------- example/3_advanced/http_server_cpp20/main.cpp | 2 +- .../3_advanced/http_server_cpp20/types.hpp | 7 + 4 files changed, 148 insertions(+), 170 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp index aec0e3886..8c00f2e58 100644 --- a/example/3_advanced/http_server_cpp20/error.hpp +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -19,11 +19,14 @@ #include +#include + namespace orders { // Error code enum for errors originated within our application enum class errc { + content_type_not_json, // A request expects application/json Content-Type, but didn't find it not_found, // couldn't retrieve or modify a certain resource because it doesn't exist order_not_editable, // an operation requires an order to be editable, but it's not order_not_pending_payment, // an operation requires an order to be pending payment, but it's not @@ -41,6 +44,18 @@ inline boost::system::error_code make_error_code(errc v) } // namespace orders +// Allows constructing error_code from errc +namespace boost { +namespace system { + +template <> +struct is_error_code_enum : std::true_type +{ +}; + +} // namespace system +} // namespace boost + //] #endif diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 1429ae39d..05edd072f 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -22,11 +22,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -45,6 +48,7 @@ #include #include +#include "error.hpp" #include "handle_request.hpp" #include "log_error.hpp" #include "repository.hpp" @@ -57,6 +61,7 @@ namespace asio = boost::asio; namespace http = boost::beast::http; namespace mysql = boost::mysql; +using boost::system::result; namespace { @@ -125,6 +130,11 @@ class request_handler return error_response(http::status::not_found, std::move(body)); } + http::response internal_server_error() const + { + return error_response(http::status::internal_server_error, {}); + } + // Creates a response with a serialized JSON body. // T should be a type with Boost.Describe metadata containing the // body data to be serialized @@ -136,38 +146,31 @@ class request_handler // 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 + result parse_json_request() const { - boost::system::error_code ec; + // Check that the request has the appropriate content type + auto it = request_.find("Content-Type"); + if (it == request_.end() || it->value() != "application/json") + { + return orders::errc::content_type_not_json; + } // Attempt to parse the request into a json::value. // This will fail if the provided body isn't valid JSON. + boost::system::error_code ec; auto val = boost::json::parse(request_.body(), ec); if (ec) return ec; @@ -177,154 +180,37 @@ class request_handler 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(); - // } - // } - // } + http::response response_from_db_error(boost::system::error_code ec) const + { + if (ec.category() == orders::get_orders_category()) + { + switch (static_cast(ec.value())) + { + case orders::errc::not_found: return not_found("The referenced entity does not exist"); + case orders::errc::product_not_found: return bad_request("The referenced product does not exist"); + case orders::errc::order_not_editable: return bad_request("The referenced order can't be edited"); + case orders::errc::order_not_pending_payment: + return bad_request("The referenced order should be pending payment, but is not"); + default: return internal_server_error(); + } + } + else + { + return internal_server_error(); + } + } + + http::response response_from_json_error(boost::system::error_code ec) const + { + if (ec == orders::errc::content_type_not_json) + { + return bad_request("Invalid Content-Type: expected 'application/json'"); + } + else + { + return bad_request("Invalid JSON"); + } + } // Constructor request_handler(const http::request& req, mysql::connection_pool& pool) noexcept @@ -369,14 +255,14 @@ asio::awaitable> handle_get_orders(request_han // 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"); + co_return handler.bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - std::optional order = co_await handler.repo_.get_order_by_id(*order_id); + result order = co_await handler.repo_.get_order_by_id(*order_id); + if (order.has_error()) + co_return handler.response_from_db_error(order.error()); // Return the response - if (!order.has_value()) - co_return handler.not_found("Order not found"); co_return handler.json_response(*order); } } @@ -390,9 +276,79 @@ asio::awaitable> handle_create_order(request_h co_return handler.json_response(order); } -asio::awaitable> handle_add_item(request_handler& handler) +asio::awaitable> handle_add_order_item(request_handler& handler) { - // TODO + // Parse the request body + auto req = handler.parse_json_request(); + if (req.has_error()) + co_return handler.response_from_json_error(req.error()); + + // Invoke the database logic + result res = co_await handler.repo_ + .add_order_item(req->order_id, req->product_id, req->quantity); + if (res.has_error()) + co_return handler.response_from_db_error(res.error()); + + // Return the response + co_return handler.json_response(*res); +} + +// TODO: reduce duplication +asio::awaitable> handle_remove_order_item(request_handler& handler) +{ + // Parse the query parameter + auto params_it = handler.target.params().find("id"); + if (params_it == handler.target.params().end()) + co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await handler.repo_.remove_order_item(*id); + if (res.has_error()) + co_return handler.response_from_db_error(res.error()); + + // Return the response + co_return handler.json_response(*res); +} + +asio::awaitable> handle_checkout_order(request_handler& handler) +{ + // Parse the query parameter + auto params_it = handler.target.params().find("id"); + if (params_it == handler.target.params().end()) + co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await handler.repo_.checkout_order(*id); + if (res.has_error()) + co_return handler.response_from_db_error(res.error()); + + // Return the response + co_return handler.json_response(*res); +} + +asio::awaitable> handle_complete_order(request_handler& handler) +{ + // Parse the query parameter + auto params_it = handler.target.params().find("id"); + if (params_it == handler.target.params().end()) + co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + auto id = parse_id((*params_it).value); + if (!id.has_value()) + co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + + // Invoke the database logic + result res = co_await handler.repo_.complete_order(*id); + if (res.has_error()) + co_return handler.response_from_db_error(res.error()); + + // Return the response + co_return handler.json_response(*res); } struct http_endpoint diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp index 776a1d441..4586f7b18 100644 --- a/example/3_advanced/http_server_cpp20/main.cpp +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -20,7 +20,7 @@ * 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. + * POST /orders/items Adds a new order item to an existing order. * DELETE /orders/items?id={} Deletes an order item * POST /orders/checkout?id={} Checks out an order * POST /orders/complete?id={} Completes an order diff --git a/example/3_advanced/http_server_cpp20/types.hpp b/example/3_advanced/http_server_cpp20/types.hpp index 1bcefb2d0..6288594c2 100644 --- a/example/3_advanced/http_server_cpp20/types.hpp +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -75,6 +75,13 @@ BOOST_DESCRIBE_STRUCT(order_with_items, (), (id, status, items)) // // REST API requests. // +struct add_order_item_request +{ + std::int64_t order_id; + std::int64_t product_id; + std::int64_t quantity; +}; +BOOST_DESCRIBE_STRUCT(add_order_item_request, (), (order_id, product_id, quantity)) // // REST API responses. From 724b30d4c0af634a4cc20328295c93de0f7e6f92 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 17:16:51 +0100 Subject: [PATCH 11/46] handle_request common part --- .../http_server_cpp20/handle_request.cpp | 327 ++++++++---------- 1 file changed, 152 insertions(+), 175 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 05edd072f..702e086eb 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -35,12 +35,9 @@ #include #include -#include #include -#include #include #include -#include #include #include #include @@ -66,7 +63,7 @@ using boost::system::result; namespace { // Attempts to parse a numeric ID from a string -static std::optional parse_id(std::string_view from) +std::optional parse_id(std::string_view from) { std::int64_t id{}; auto res = std::from_chars(from.data(), from.data() + from.size(), id); @@ -75,148 +72,123 @@ static std::optional parse_id(std::string_view from) 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 +// Creates an error response +http::response error_response(http::status code, std::string_view msg) { -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; + http::response res; + res.result(code); + res.body() = msg; + return res; +} - // The repository to access MySQL - orders::db_repository repo_; +// TODO +http::response bad_request(std::string body) +{ + return error_response(http::status::bad_request, std::move(body)); +} - // 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; - } +// Used when the user requested a note (e.g. using GET /note/ or PUT /note/) +// but the note doesn't exist +http::response not_found(std::string body = "The requested resource was not found") +{ + return error_response(http::status::not_found, std::move(body)); +} - // TODO - http::response bad_request(std::string body) const - { - return error_response(http::status::bad_request, std::move(body)); - } +http::response internal_server_error() +{ + return error_response(http::status::internal_server_error, {}); +} - // 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"); - } +// Creates a response with a serialized JSON body. +// T should be a type with Boost.Describe metadata containing the +// body data to be serialized +template +http::response json_response(const T& body) +{ + http::response res; - // 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"); - } + // Set the content-type header + res.set("Content-Type", "application/json"); - // 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"); - } + // 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)); - // 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)); - } + // Done + return res; +} - http::response internal_server_error() const +// 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 +result parse_json_request(const http::request& request) +{ + // Check that the request has the appropriate content type + auto it = request.find("Content-Type"); + if (it == request.end() || it->value() != "application/json") { - return error_response(http::status::internal_server_error, {}); + return orders::errc::content_type_not_json; } - // 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"); - - // 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)); + // Attempt to parse the request into a json::value. + // This will fail if the provided body isn't valid JSON. + boost::system::error_code ec; + auto val = boost::json::parse(request.body(), ec); + if (ec) + return ec; - // Done - return res; - } + // 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); +} - // 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 - result parse_json_request() const +http::response response_from_db_error(boost::system::error_code ec) +{ + if (ec.category() == orders::get_orders_category()) { - // Check that the request has the appropriate content type - auto it = request_.find("Content-Type"); - if (it == request_.end() || it->value() != "application/json") + switch (static_cast(ec.value())) { - return orders::errc::content_type_not_json; + case orders::errc::not_found: return not_found("The referenced entity does not exist"); + case orders::errc::product_not_found: return bad_request("The referenced product does not exist"); + case orders::errc::order_not_editable: return bad_request("The referenced order can't be edited"); + case orders::errc::order_not_pending_payment: + return bad_request("The referenced order should be pending payment, but is not"); + default: return internal_server_error(); } - - // Attempt to parse the request into a json::value. - // This will fail if the provided body isn't valid JSON. - boost::system::error_code ec; - auto val = boost::json::parse(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); } - - http::response response_from_db_error(boost::system::error_code ec) const + else { - if (ec.category() == orders::get_orders_category()) - { - switch (static_cast(ec.value())) - { - case orders::errc::not_found: return not_found("The referenced entity does not exist"); - case orders::errc::product_not_found: return bad_request("The referenced product does not exist"); - case orders::errc::order_not_editable: return bad_request("The referenced order can't be edited"); - case orders::errc::order_not_pending_payment: - return bad_request("The referenced order should be pending payment, but is not"); - default: return internal_server_error(); - } - } - else - { - return internal_server_error(); - } + return internal_server_error(); } +} - http::response response_from_json_error(boost::system::error_code ec) const +http::response response_from_json_error(boost::system::error_code ec) +{ + if (ec == orders::errc::content_type_not_json) { - if (ec == orders::errc::content_type_not_json) - { - return bad_request("Invalid Content-Type: expected 'application/json'"); - } - else - { - return bad_request("Invalid JSON"); - } + return bad_request("Invalid Content-Type: expected 'application/json'"); } - - // Constructor - request_handler(const http::request& req, mysql::connection_pool& pool) noexcept - : request_(req), repo_(pool) + else { + return bad_request("Invalid JSON"); } +} + +// Encapsulates the logic required to match a HTTP request +// to an API endpoint, call the relevant note_repository function, +// and return an HTTP response. +struct request_handler +{ + // The incoming request + const http::request& request; + + // The URL the request is targeting + boost::urls::url_view target; + + // The repository to access MySQL + orders::db_repository repo; }; // GET /products: search for available products @@ -225,14 +197,14 @@ asio::awaitable> handle_get_products(request_h // 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'"); + co_return 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); + std::vector products = co_await handler.repo.get_products(search); // Return the response - co_return handler.json_response(products); + co_return json_response(products); } asio::awaitable> handle_get_orders(request_handler& handler) @@ -244,10 +216,10 @@ asio::awaitable> handle_get_orders(request_han { // If the query parameter is not present, return all orders // Invoke the database logic - std::vector orders = co_await handler.repo_.get_orders(); + std::vector orders = co_await handler.repo.get_orders(); // Return the response - co_return handler.json_response(orders); + co_return json_response(orders); } else { @@ -255,42 +227,42 @@ asio::awaitable> handle_get_orders(request_han // Parse the query parameter auto order_id = parse_id((*params_it).value); if (!order_id.has_value()) - co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result order = co_await handler.repo_.get_order_by_id(*order_id); + result order = co_await handler.repo.get_order_by_id(*order_id); if (order.has_error()) - co_return handler.response_from_db_error(order.error()); + co_return response_from_db_error(order.error()); // Return the response - co_return handler.json_response(*order); + co_return 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(); + orders::order_with_items order = co_await handler.repo.create_order(); // Return the response - co_return handler.json_response(order); + co_return json_response(order); } asio::awaitable> handle_add_order_item(request_handler& handler) { // Parse the request body - auto req = handler.parse_json_request(); + auto req = parse_json_request(handler.request); if (req.has_error()) - co_return handler.response_from_json_error(req.error()); + co_return response_from_json_error(req.error()); // Invoke the database logic - result res = co_await handler.repo_ + result res = co_await handler.repo .add_order_item(req->order_id, req->product_id, req->quantity); if (res.has_error()) - co_return handler.response_from_db_error(res.error()); + co_return response_from_db_error(res.error()); // Return the response - co_return handler.json_response(*res); + co_return json_response(*res); } // TODO: reduce duplication @@ -299,18 +271,18 @@ asio::awaitable> handle_remove_order_item(requ // Parse the query parameter auto params_it = handler.target.params().find("id"); if (params_it == handler.target.params().end()) - co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) - co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo_.remove_order_item(*id); + result res = co_await handler.repo.remove_order_item(*id); if (res.has_error()) - co_return handler.response_from_db_error(res.error()); + co_return response_from_db_error(res.error()); // Return the response - co_return handler.json_response(*res); + co_return json_response(*res); } asio::awaitable> handle_checkout_order(request_handler& handler) @@ -318,18 +290,18 @@ asio::awaitable> handle_checkout_order(request // Parse the query parameter auto params_it = handler.target.params().find("id"); if (params_it == handler.target.params().end()) - co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) - co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo_.checkout_order(*id); + result res = co_await handler.repo.checkout_order(*id); if (res.has_error()) - co_return handler.response_from_db_error(res.error()); + co_return response_from_db_error(res.error()); // Return the response - co_return handler.json_response(*res); + co_return json_response(*res); } asio::awaitable> handle_complete_order(request_handler& handler) @@ -337,18 +309,18 @@ asio::awaitable> handle_complete_order(request // Parse the query parameter auto params_it = handler.target.params().find("id"); if (params_it == handler.target.params().end()) - co_return handler.bad_request("Mandatory URL parameter 'id' not found"); + co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) - co_return handler.bad_request("URL parameter 'id' should be a valid integer"); + co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo_.complete_order(*id); + result res = co_await handler.repo.complete_order(*id); if (res.has_error()) - co_return handler.response_from_db_error(res.error()); + co_return response_from_db_error(res.error()); // Return the response - co_return handler.json_response(*res); + co_return json_response(*res); } struct http_endpoint @@ -358,9 +330,13 @@ struct http_endpoint }; 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}} + {"/products", {http::verb::get, &handle_get_products} }, + {"/orders", {http::verb::get, &handle_get_orders} }, + {"/orders", {http::verb::post, &handle_create_order} }, + {"/orders/items", {http::verb::post, &handle_add_order_item} }, + {"/orders/items", {http::verb::delete_, &handle_remove_order_item}}, + {"/orders/checkout", {http::verb::post, &handle_checkout_order} }, + {"/orders/complete", {http::verb::post, &handle_complete_order} }, }; } // namespace @@ -371,52 +347,53 @@ asio::awaitable> orders::handle_request( mysql::connection_pool& pool ) { - request_handler handler(pool); + // Parse the request target + auto target = boost::urls::parse_origin_form(request.target()); + if (!target.has_value()) + co_return bad_request("Invalid request target"); // Try to find an endpoint - auto it = endpoint_map.find(handler.target.path()); - if (it == endpoint_map.end()) - { - co_return handler.endpoint_not_found(); - } + auto [it1, it2] = endpoint_map.equal_range(target->path()); + if (it1 == endpoint_map.end()) + co_return not_found("The request endpoint does not exist"); // Match the verb - auto it2 = std::find_if( + auto it3 = std::find_if(it1, it2, [&request](const std::pair& ep) { + return ep.second.method == request.method(); + }); + if (it3 == endpoint_map.end()) + co_return error_response(http::status::method_not_allowed, "Unsupported HTTP method"); - ) + // Compose the data struct (TODO) + request_handler h{request, *target, pool}; + // Invoke the handler 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) - ); + // Attempt to handle the request + co_return co_await it3->second.handler(h); } - catch (const boost::mysql::error_with_diagnostics& err) + catch (const mysql::error_with_diagnostics& err) { // A Boost.MySQL error. This will happen if you don't have connectivity // to your database, your schema is incorrect or your credentials are invalid. // Log the error, including diagnostics, and return a generic 500 + // TODO log_error( "Uncaught exception: ", err.what(), "\nServer diagnostics: ", err.get_diagnostics().server_message() ); - return error_response(http::status::internal_server_error, "Internal error"); + co_return internal_server_error(); } catch (const std::exception& err) { // Another kind of error. This indicates a programming error or a severe // server condition (e.g. out of memory). Same procedure as above. log_error("Uncaught exception: ", err.what()); - return error_response(http::status::internal_server_error, "Internal error"); + co_return internal_server_error(); } } //] - -#endif From 8796420188bfb665780983113dd8be7fd8088899 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 17:20:31 +0100 Subject: [PATCH 12/46] Simplify JSON request parsing --- .../3_advanced/http_server_cpp20/error.hpp | 1 - .../http_server_cpp20/handle_request.cpp | 34 ++++++------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp index 8c00f2e58..9857e7d02 100644 --- a/example/3_advanced/http_server_cpp20/error.hpp +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -26,7 +26,6 @@ namespace orders { // Error code enum for errors originated within our application enum class errc { - content_type_not_json, // A request expects application/json Content-Type, but didn't find it not_found, // couldn't retrieve or modify a certain resource because it doesn't exist order_not_editable, // an operation requires an order to be editable, but it's not order_not_pending_payment, // an operation requires an order to be pending payment, but it's not diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 702e086eb..b244f2b37 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -119,23 +119,16 @@ http::response json_response(const T& body) return res; } -// Attempts to parse the request body as a JSON into an object of type T. +// Attempts to parse a string as a JSON into an object of type T. // T should be a type with Boost.Describe metadata. // We use boost::system::result, which may contain a result or an error. template -result parse_json_request(const http::request& request) +result parse_json(std::string_view json_string) { - // Check that the request has the appropriate content type - auto it = request.find("Content-Type"); - if (it == request.end() || it->value() != "application/json") - { - return orders::errc::content_type_not_json; - } - // Attempt to parse the request into a json::value. // This will fail if the provided body isn't valid JSON. boost::system::error_code ec; - auto val = boost::json::parse(request.body(), ec); + auto val = boost::json::parse(json_string, ec); if (ec) return ec; @@ -164,18 +157,6 @@ http::response response_from_db_error(boost::system::error_co } } -http::response response_from_json_error(boost::system::error_code ec) -{ - if (ec == orders::errc::content_type_not_json) - { - return bad_request("Invalid Content-Type: expected 'application/json'"); - } - else - { - return bad_request("Invalid JSON"); - } -} - // Encapsulates the logic required to match a HTTP request // to an API endpoint, call the relevant note_repository function, // and return an HTTP response. @@ -250,10 +231,15 @@ asio::awaitable> handle_create_order(request_h asio::awaitable> handle_add_order_item(request_handler& handler) { + // Check that the request has the appropriate content type + auto it = handler.request.find("Content-Type"); + if (it == handler.request.end() || it->value() != "application/json") + co_return bad_request("Invalid Content-Type: expected 'application/json'"); + // Parse the request body - auto req = parse_json_request(handler.request); + auto req = parse_json(handler.request.body()); if (req.has_error()) - co_return response_from_json_error(req.error()); + co_return bad_request("Invalid JSON body"); // Invoke the database logic result res = co_await handler.repo From a84e3e9055c80c5a89636eea732babf43ee41221 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 17:26:42 +0100 Subject: [PATCH 13/46] request_data --- .../http_server_cpp20/handle_request.cpp | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index b244f2b37..2c7d892ca 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -157,10 +157,9 @@ http::response response_from_db_error(boost::system::error_co } } -// Encapsulates the logic required to match a HTTP request -// to an API endpoint, call the relevant note_repository function, -// and return an HTTP response. -struct request_handler +// Contains data associated to an HTTP request. +// To be passed to individual handler functions +struct request_data { // The incoming request const http::request& request; @@ -168,36 +167,38 @@ struct request_handler // The URL the request is targeting boost::urls::url_view target; - // The repository to access MySQL - orders::db_repository repo; + // Connection pool + mysql::connection_pool& pool; + + orders::db_repository repo() const { return orders::db_repository(pool); } }; // GET /products: search for available products -asio::awaitable> handle_get_products(request_handler& handler) +asio::awaitable> handle_get_products(const request_data& input) { // Parse the query parameter - auto params_it = handler.target.params().find("search"); - if (params_it == handler.target.params().end()) + auto params_it = input.target.params().find("search"); + if (params_it == input.target.params().end()) co_return bad_request("Missing mandatory query parameter: 'search'"); auto search = (*params_it).value; // Invoke the database logic - std::vector products = co_await handler.repo.get_products(search); + std::vector products = co_await input.repo().get_products(search); // Return the response co_return json_response(products); } -asio::awaitable> handle_get_orders(request_handler& handler) +asio::awaitable> handle_get_orders(const request_data& input) { // Parse the query parameter - auto params_it = handler.target.params().find("id"); + auto params_it = input.target.params().find("id"); - if (params_it == handler.target.params().end()) + if (params_it == input.target.params().end()) { // If the query parameter is not present, return all orders // Invoke the database logic - std::vector orders = co_await handler.repo.get_orders(); + std::vector orders = co_await input.repo().get_orders(); // Return the response co_return json_response(orders); @@ -211,7 +212,7 @@ asio::awaitable> handle_get_orders(request_han co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result order = co_await handler.repo.get_order_by_id(*order_id); + result order = co_await input.repo().get_order_by_id(*order_id); if (order.has_error()) co_return response_from_db_error(order.error()); @@ -220,29 +221,29 @@ asio::awaitable> handle_get_orders(request_han } } -asio::awaitable> handle_create_order(request_handler& handler) +asio::awaitable> handle_create_order(const request_data& input) { // Invoke the database logic - orders::order_with_items order = co_await handler.repo.create_order(); + orders::order_with_items order = co_await input.repo().create_order(); // Return the response co_return json_response(order); } -asio::awaitable> handle_add_order_item(request_handler& handler) +asio::awaitable> handle_add_order_item(const request_data& input) { // Check that the request has the appropriate content type - auto it = handler.request.find("Content-Type"); - if (it == handler.request.end() || it->value() != "application/json") + auto it = input.request.find("Content-Type"); + if (it == input.request.end() || it->value() != "application/json") co_return bad_request("Invalid Content-Type: expected 'application/json'"); // Parse the request body - auto req = parse_json(handler.request.body()); + auto req = parse_json(input.request.body()); if (req.has_error()) co_return bad_request("Invalid JSON body"); // Invoke the database logic - result res = co_await handler.repo + result res = co_await input.repo() .add_order_item(req->order_id, req->product_id, req->quantity); if (res.has_error()) co_return response_from_db_error(res.error()); @@ -252,18 +253,18 @@ asio::awaitable> handle_add_order_item(request } // TODO: reduce duplication -asio::awaitable> handle_remove_order_item(request_handler& handler) +asio::awaitable> handle_remove_order_item(const request_data& input) { // Parse the query parameter - auto params_it = handler.target.params().find("id"); - if (params_it == handler.target.params().end()) + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo.remove_order_item(*id); + result res = co_await input.repo().remove_order_item(*id); if (res.has_error()) co_return response_from_db_error(res.error()); @@ -271,18 +272,18 @@ asio::awaitable> handle_remove_order_item(requ co_return json_response(*res); } -asio::awaitable> handle_checkout_order(request_handler& handler) +asio::awaitable> handle_checkout_order(const request_data& input) { // Parse the query parameter - auto params_it = handler.target.params().find("id"); - if (params_it == handler.target.params().end()) + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo.checkout_order(*id); + result res = co_await input.repo().checkout_order(*id); if (res.has_error()) co_return response_from_db_error(res.error()); @@ -290,18 +291,18 @@ asio::awaitable> handle_checkout_order(request co_return json_response(*res); } -asio::awaitable> handle_complete_order(request_handler& handler) +asio::awaitable> handle_complete_order(const request_data& input) { // Parse the query parameter - auto params_it = handler.target.params().find("id"); - if (params_it == handler.target.params().end()) + auto params_it = input.target.params().find("id"); + if (params_it == input.target.params().end()) co_return bad_request("Mandatory URL parameter 'id' not found"); auto id = parse_id((*params_it).value); if (!id.has_value()) co_return bad_request("URL parameter 'id' should be a valid integer"); // Invoke the database logic - result res = co_await handler.repo.complete_order(*id); + result res = co_await input.repo().complete_order(*id); if (res.has_error()) co_return response_from_db_error(res.error()); @@ -312,10 +313,10 @@ asio::awaitable> handle_complete_order(request struct http_endpoint { http::verb method; - asio::awaitable> (*handler)(request_handler&); + asio::awaitable> (*handler)(const request_data&); }; -const std::unordered_multimap endpoint_map{ +const std::unordered_multimap endpoints{ {"/products", {http::verb::get, &handle_get_products} }, {"/orders", {http::verb::get, &handle_get_orders} }, {"/orders", {http::verb::post, &handle_create_order} }, @@ -339,19 +340,19 @@ asio::awaitable> orders::handle_request( co_return bad_request("Invalid request target"); // Try to find an endpoint - auto [it1, it2] = endpoint_map.equal_range(target->path()); - if (it1 == endpoint_map.end()) + auto [it1, it2] = endpoints.equal_range(target->path()); + if (it1 == endpoints.end()) co_return not_found("The request endpoint does not exist"); // Match the verb auto it3 = std::find_if(it1, it2, [&request](const std::pair& ep) { return ep.second.method == request.method(); }); - if (it3 == endpoint_map.end()) + if (it3 == endpoints.end()) co_return error_response(http::status::method_not_allowed, "Unsupported HTTP method"); // Compose the data struct (TODO) - request_handler h{request, *target, pool}; + request_data h{request, *target, pool}; // Invoke the handler try From cd9388c8aac207788f6b4029a452649d8b09cb44 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 17:54:51 +0100 Subject: [PATCH 14/46] Proper error logging --- .../3_advanced/http_server_cpp20/error.hpp | 11 +++++ .../http_server_cpp20/handle_request.cpp | 48 ++++++++++++++----- .../http_server_cpp20/log_error.hpp | 41 ---------------- .../3_advanced/http_server_cpp20/server.cpp | 4 +- 4 files changed, 50 insertions(+), 54 deletions(-) delete mode 100644 example/3_advanced/http_server_cpp20/log_error.hpp diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp index 9857e7d02..69c4c35e4 100644 --- a/example/3_advanced/http_server_cpp20/error.hpp +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -19,6 +19,8 @@ #include +#include +#include #include namespace orders { @@ -41,6 +43,15 @@ inline boost::system::error_code make_error_code(errc v) return boost::system::error_code(static_cast(v), get_orders_category()); } +// In multi-threaded programs, using std::cerr without any locking +// can result in interleaved output. +// Locks a mutex guarding std::cerr to prevent this. +// All uses of std::cerr should respect this. +std::unique_lock lock_cerr(); + +// A helper function for the common case where we want to log an error code +void log_error(std::string_view header, boost::system::error_code ec); + } // namespace orders // Allows constructing error_code from errc diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 2c7d892ca..a35d6e09a 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -6,6 +6,7 @@ // #include +#include #include //[example_connection_pool_handle_request_cpp @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -47,7 +49,6 @@ #include "error.hpp" #include "handle_request.hpp" -#include "log_error.hpp" #include "repository.hpp" #include "types.hpp" @@ -62,6 +63,33 @@ using boost::system::result; namespace { +void log_mysql_error(boost::system::error_code ec, const mysql::diagnostics& diag) +{ + // Lock std::cerr, to avoid race conditions + auto guard = orders::lock_cerr(); + + // Inserting the error code only prints the number and category. Add the message, too. + std::cerr << "MySQL error: " << ec << " " << ec.message(); + + // client_message() contains client-side generated messages that don't + // contain user-input. This is usually embedded in exceptions. + // When working with error codes, we need to log it explicitly + if (!diag.client_message().empty()) + { + std::cerr << ": " << diag.client_message(); + } + + // server_message() contains server-side messages, and thus may + // contain user-supplied input. Printing it is safe. + if (!diag.server_message().empty()) + { + std::cerr << ": " << diag.server_message(); + } + + // Done + std::cerr << std::endl; +} + // Attempts to parse a numeric ID from a string std::optional parse_id(std::string_view from) { @@ -252,7 +280,6 @@ asio::awaitable> handle_add_order_item(const r co_return json_response(*res); } -// TODO: reduce duplication asio::awaitable> handle_remove_order_item(const request_data& input) { // Parse the query parameter @@ -364,21 +391,20 @@ asio::awaitable> orders::handle_request( { // 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 - // TODO - log_error( - "Uncaught exception: ", - err.what(), - "\nServer diagnostics: ", - err.get_diagnostics().server_message() - ); + // Log the error, including diagnostics + log_mysql_error(err.code(), err.get_diagnostics()); + + // Never disclose error info to a potential attacker co_return internal_server_error(); } catch (const std::exception& err) { // Another kind of error. This indicates a programming error or a severe // server condition (e.g. out of memory). Same procedure as above. - log_error("Uncaught exception: ", err.what()); + { + auto guard = orders::lock_cerr(); + std::cerr << "Uncaught exception: " << err.what() << std::endl; + } co_return internal_server_error(); } } diff --git a/example/3_advanced/http_server_cpp20/log_error.hpp b/example/3_advanced/http_server_cpp20/log_error.hpp deleted file mode 100644 index acd9d17a5..000000000 --- a/example/3_advanced/http_server_cpp20/log_error.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// -// Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// - -#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_LOG_ERROR_HPP -#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_LOG_ERROR_HPP - -//[example_connection_pool_log_error_hpp -// -// File: log_error.hpp -// -// 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. - -#include -#include - -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/server.cpp b/example/3_advanced/http_server_cpp20/server.cpp index 4e4f672a3..82eaed5bf 100644 --- a/example/3_advanced/http_server_cpp20/server.cpp +++ b/example/3_advanced/http_server_cpp20/server.cpp @@ -18,6 +18,8 @@ #include #include + +#include "error.hpp" #if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED //[example_connection_pool_server_cpp @@ -52,8 +54,6 @@ #include "handle_request.hpp" #include "server.hpp" -// #include "types.hpp" -#include "log_error.hpp" namespace asio = boost::asio; namespace http = boost::beast::http; From c373d7f68dd1d813fcef058cc88f0e933b3fc652 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 17:58:54 +0100 Subject: [PATCH 15/46] locking std::cerr impl --- .../3_advanced/http_server_cpp20/error.cpp | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp index 4e424dfd2..60fb51434 100644 --- a/example/3_advanced/http_server_cpp20/error.cpp +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -9,6 +9,9 @@ #include +#include +#include + #include "error.hpp" namespace { @@ -40,10 +43,28 @@ class orders_category final : public boost::system::error_category } }; -static const orders_category cat; +// The error category +static const orders_category g_category; + +// The std::mutex that guards std::cerr +static std::mutex g_cerr_mutex; } // namespace -const boost::system::error_category& orders::get_orders_category() { return cat; } +// +// External interface +// +const boost::system::error_category& orders::get_orders_category() { return g_category; } + +std::unique_lock orders::lock_cerr() { return std::unique_lock{g_cerr_mutex}; } + +void orders::log_error(std::string_view header, boost::system::error_code ec) +{ + // Lock the mutex + auto guard = lock_cerr(); + + // Logging the error code prints the number and category. Add the message, too + std::cerr << header << ": " << ec << " " << ec.message() << std::endl; +} //] \ No newline at end of file From adf293e4d2a954dcbd777eb8d5ce9d7558f9579b Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 18:15:02 +0100 Subject: [PATCH 16/46] Unify invalid status error --- example/3_advanced/http_server_cpp20/error.cpp | 3 +-- example/3_advanced/http_server_cpp20/error.hpp | 7 +++---- example/3_advanced/http_server_cpp20/handle_request.cpp | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp index 60fb51434..104b5cd61 100644 --- a/example/3_advanced/http_server_cpp20/error.cpp +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -22,8 +22,7 @@ const char* error_to_string(orders::errc value) switch (value) { case orders::errc::not_found: return "not_found"; - case orders::errc::order_not_editable: return "order_not_editable"; - case orders::errc::order_not_pending_payment: return "order_not_pending_payment"; + case orders::errc::order_invalid_status: return "order_invalid_status"; case orders::errc::product_not_found: return "product_not_found"; default: return ""; } diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp index 69c4c35e4..b0d3ebf9f 100644 --- a/example/3_advanced/http_server_cpp20/error.hpp +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -28,10 +28,9 @@ namespace orders { // Error code enum for errors originated within our application enum class errc { - not_found, // couldn't retrieve or modify a certain resource because it doesn't exist - order_not_editable, // an operation requires an order to be editable, but it's not - order_not_pending_payment, // an operation requires an order to be pending payment, but it's not - product_not_found, // a product referenced by a request doesn't exist + not_found, // couldn't retrieve or modify a certain resource because it doesn't exist + order_invalid_status, // an operation found an order in a status != the one expected (e.g. not editable) + product_not_found, // a product referenced by a request doesn't exist }; // The error category for errc diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index a35d6e09a..3c87653ba 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -173,9 +173,8 @@ http::response response_from_db_error(boost::system::error_co { case orders::errc::not_found: return not_found("The referenced entity does not exist"); case orders::errc::product_not_found: return bad_request("The referenced product does not exist"); - case orders::errc::order_not_editable: return bad_request("The referenced order can't be edited"); - case orders::errc::order_not_pending_payment: - return bad_request("The referenced order should be pending payment, but is not"); + case orders::errc::order_invalid_status: + return bad_request("The referenced order doesn't have the status required by the operation"); default: return internal_server_error(); } } From 600bf4a0d2a1e70738fe89c89dce2c21d5dc887c Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 18:15:18 +0100 Subject: [PATCH 17/46] Repo docs and error impl 1 --- .../http_server_cpp20/repository.cpp | 61 ++++++------------- .../http_server_cpp20/repository.hpp | 51 +++++++++++----- 2 files changed, 54 insertions(+), 58 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 55cea0ab6..50723d0a1 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -12,21 +12,21 @@ // // File: repository.cpp // +// See (TODO: link this) for the table definitions #include -#include #include -#include -#include #include #include +#include -#include #include #include #include +#include +#include "error.hpp" #include "repository.hpp" #include "types.hpp" @@ -34,32 +34,6 @@ 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 @@ -110,7 +84,7 @@ asio::awaitable> db_repository::get_orders() co_return std::vector{res.rows().begin(), res.rows().end()}; } -asio::awaitable> db_repository::get_order_by_id(std::int64_t id) +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(); @@ -140,7 +114,7 @@ asio::awaitable> db_repository::get_order_by_id( // Did we find the order we're looking for? if (orders.empty()) - co_return std::nullopt; + co_return orders::errc::not_found; const order& ord = orders[0]; // If we did, compose the result @@ -179,8 +153,7 @@ asio::awaitable db_repository::create_order() co_return result.rows<2>().front(); } -// TODO: we should probably use system::result to communicate what happened -asio::awaitable> db_repository::add_order_item( +asio::awaitable> db_repository::add_order_item( std::int64_t order_id, std::int64_t product_id, std::int64_t quantity @@ -211,7 +184,7 @@ asio::awaitable> db_repository::add_order_item( { // Not found. We did mutate session state by opening a transaction, // so we can't use return_without_reset - co_return std::nullopt; + co_return orders::errc::not_found; } const order& ord = result1.rows<1>().front(); @@ -219,13 +192,13 @@ asio::awaitable> db_repository::add_order_item( // Using SELECT ... FOR SHARE prevents race conditions with this check. if (ord.status != status_draft) { - co_return std::nullopt; + co_return orders::errc::order_invalid_status; } // Check that the product exists if (result1.rows<2>().empty()) { - co_return std::nullopt; + co_return orders::errc::product_not_found; } // Insert the new item and retrieve all the items associated to this order @@ -253,13 +226,15 @@ asio::awaitable> db_repository::add_order_item( }; } -asio::awaitable> db_repository::remove_order_item(std::int64_t item_id) +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. + // TODO: this is wrong mysql::static_results, std::tuple<>, order, order_item, std::tuple<>> result; co_await conn->async_execute( mysql::with_params( @@ -306,7 +281,7 @@ asio::awaitable> db_repository::remove_order_ite } // Helper function to implement checkout_order and complete_order -static asio::awaitable> change_order_status( +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 @@ -333,13 +308,13 @@ static asio::awaitable> change_order_status( // Check that the order exists if (result1.rows<1>().empty()) { - co_return std::nullopt; + co_return orders::errc::not_found; } // Check that the order is in the expected status if (std::get<0>(result1.rows<1>().front()) != original_status) { - co_return std::nullopt; + co_return orders::errc::order_invalid_status; } // Update the order and retrieve the order details @@ -366,12 +341,12 @@ static asio::awaitable> change_order_status( }; } -asio::awaitable> db_repository::checkout_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) +asio::awaitable> db_repository::complete_order(std::int64_t id) { return change_order_status(pool_, id, status_pending_payment, status_complete); } diff --git a/example/3_advanced/http_server_cpp20/repository.hpp b/example/3_advanced/http_server_cpp20/repository.hpp index 60d3a9d5c..1b5521562 100644 --- a/example/3_advanced/http_server_cpp20/repository.hpp +++ b/example/3_advanced/http_server_cpp20/repository.hpp @@ -5,8 +5,8 @@ // 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 +#ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP +#define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP //[example_connection_pool_repository_hpp // @@ -16,9 +16,9 @@ #include #include +#include #include -#include #include #include @@ -26,12 +26,10 @@ 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. +// Encapsulates database logic. +// If the database is unavailable, all of these functions throw. +// Additionally, functions that may fail depending on the supplied input +// return boost::system::result to avoid exceptions in common cases. class db_repository { boost::mysql::connection_pool& pool_; @@ -40,19 +38,42 @@ class db_repository // 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 + // Retrieves products using a full-text search boost::asio::awaitable> get_products(std::string_view search); + + // Retrieves all the orders in the database boost::asio::awaitable> get_orders(); - boost::asio::awaitable> get_order_by_id(std::int64_t id); + + // Retrieves an order by ID. + // Returns an error if the ID doesn't match any order. + boost::asio::awaitable> get_order_by_id(std::int64_t id); + + // Creates an empty order. Returns the created order. boost::asio::awaitable create_order(); - boost::asio::awaitable> add_order_item( + + // Adds an item to an order. Retrieves the updated order. + // Returns an error if the ID doesn't match any order, the order + // is not editable, or the product_id doesn't match any product + boost::asio::awaitable> add_order_item( std::int64_t order_id, std::int64_t product_id, std::int64_t quantity ); - 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); + + // Removes an item from an order. Retrieves the updated order. + // Returns an error if the ID doesn't match any order item + // or the order is not editable. + boost::asio::awaitable> remove_order_item(std::int64_t item_id); + + // Checks an order out, transitioning it to the pending_payment status. + // Returns an error if the ID doesn't match any order + // or the order is not editable. + boost::asio::awaitable> checkout_order(std::int64_t id); + + // Completes an order, transitioning it to the complete status. + // Returns an error if the ID doesn't match any order + // or the order is not checked out. + boost::asio::awaitable> complete_order(std::int64_t id); }; } // namespace orders From 424796b0d3a2589e35e233008197d77c0bf8fb59 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Sun, 15 Dec 2024 18:50:13 +0100 Subject: [PATCH 18/46] Sanitize the DELETE --- .../http_server_cpp20/repository.cpp | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 50723d0a1..163bb9136 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -232,51 +232,57 @@ asio::awaitable> db_repository::remove_o // 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. - // TODO: this is wrong - mysql::static_results, std::tuple<>, order, order_item, std::tuple<>> result; + // Retrieve the order. + // SELECT ... FOR SHARE places a shared lock on the order and the item, + // so they're not modified by other transactions while we use them. + mysql::static_results, order> result1; co_await conn->async_execute( mysql::with_params( "START TRANSACTION;" - "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", + " JOIN order_items it ON (ord.id = it.order_id)" + " WHERE it.id = {} FOR SHARE", item_id ), - result + result1 ); - // We didn't mutate session state - conn.return_without_reset(); - - // Check that the order exists - if (result.rows<2>().empty()) + // Check that the item exists + if (result1.rows<1>().empty()) { // Not found. We did mutate session state by opening a transaction, // so we can't use return_without_reset - co_return std::nullopt; + co_return orders::errc::not_found; } - const order& ord = result.rows<2>().front(); + const order& ord = result1.rows<1>().front(); - // Check that the item was deleted - if (result.affected_rows<1>() == 0u) + // Check that the order is editable + if (ord.status != orders::status_draft) { - // Nothing was deleted - co_return std::nullopt; + co_return orders::errc::order_invalid_status; } + // Perform the deletion and retrieve the items + mysql::static_results, order_item, std::tuple<>> result2; + co_await conn->async_execute( + mysql::with_params( + "DELETE FROM order_items WHERE id = {};" + "SELECT id, product_id, quantity FROM order_items WHERE order_id = {};" + "COMMIT", + item_id, + ord.id + ), + result2 + ); + + // If everything went well, we didn't mutate session state + conn.return_without_reset(); + // Compose the return value co_return order_with_items{ ord.id, ord.status, - {result.rows<3>().begin(), result.rows<3>().end()} + {result2.rows<1>().begin(), result2.rows<1>().end()} }; } From 52e2482215d59a97df8547aa1928dcb26c29696f Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:10:51 +0100 Subject: [PATCH 19/46] C++20 guards --- .../3_advanced/http_server_cpp20/error.cpp | 2 +- .../http_server_cpp20/handle_request.cpp | 11 +++++--- example/3_advanced/http_server_cpp20/main.cpp | 3 ++- .../http_server_cpp20/repository.cpp | 3 ++- .../3_advanced/http_server_cpp20/server.cpp | 26 +++++++++---------- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp index 104b5cd61..da81aa834 100644 --- a/example/3_advanced/http_server_cpp20/error.cpp +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -66,4 +66,4 @@ void orders::log_error(std::string_view header, boost::system::error_code ec) std::cerr << header << ": " << ec << " " << ec.message() << std::endl; } -//] \ No newline at end of file +//] diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 3c87653ba..a4ddf062a 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -5,17 +5,20 @@ // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) // -#include -#include -#include +#include +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED //[example_connection_pool_handle_request_cpp // // File: handle_request.cpp // +#include +#include #include #include +#include #include #include @@ -409,3 +412,5 @@ asio::awaitable> orders::handle_request( } //] + +#endif diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp index 4586f7b18..2854af93b 100644 --- a/example/3_advanced/http_server_cpp20/main.cpp +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -6,7 +6,8 @@ // #include -#if defined(BOOST_ASIO_HAS_CO_AWAIT) +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED //[example_http_server_cpp20_main_cpp diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 163bb9136..ee840978c 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -6,7 +6,8 @@ // #include -#if defined(BOOST_ASIO_HAS_CO_AWAIT) +#include +#if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED //[example_connection_pool_repository_cpp // diff --git a/example/3_advanced/http_server_cpp20/server.cpp b/example/3_advanced/http_server_cpp20/server.cpp index 82eaed5bf..6197559d4 100644 --- a/example/3_advanced/http_server_cpp20/server.cpp +++ b/example/3_advanced/http_server_cpp20/server.cpp @@ -5,21 +5,8 @@ // 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 - -#include "error.hpp" +#include #if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED //[example_connection_pool_server_cpp @@ -33,16 +20,24 @@ #include #include +#include +#include +#include +#include #include #include +#include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include @@ -51,7 +46,10 @@ #include #include #include +#include +#include +#include "error.hpp" #include "handle_request.hpp" #include "server.hpp" From 0b25b8f4726729b1f47d5e2dd7b2c8e0dae4b1d1 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:14:34 +0100 Subject: [PATCH 20/46] error.hpp comments --- example/3_advanced/http_server_cpp20/error.hpp | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/error.hpp b/example/3_advanced/http_server_cpp20/error.hpp index b0d3ebf9f..fdefe9178 100644 --- a/example/3_advanced/http_server_cpp20/error.hpp +++ b/example/3_advanced/http_server_cpp20/error.hpp @@ -13,7 +13,7 @@ // File: error.hpp // // Contains an errc enumeration and the required pieces to -// use it with Boost.System error codes. +// use it with boost::system::error_code. // We use this indirectly in the DB repository class, // when using the error codes in boost::system::result. @@ -33,12 +33,14 @@ enum class errc product_not_found, // a product referenced by a request doesn't exist }; -// The error category for errc +// To use errc with boost::system::error_code, we need +// to define an error category (see the cpp file). const boost::system::error_category& get_orders_category(); -// Allows constructing error_code from errc +// Called when constructing an error_code from an errc value. inline boost::system::error_code make_error_code(errc v) { + // Roughly, an error_code is an int and a category defining what the int means. return boost::system::error_code(static_cast(v), get_orders_category()); } @@ -53,18 +55,12 @@ void log_error(std::string_view header, boost::system::error_code ec); } // namespace orders -// Allows constructing error_code from errc -namespace boost { -namespace system { - +// This specialization is required to construct error_code's from errc values template <> -struct is_error_code_enum : std::true_type +struct boost::system::is_error_code_enum : std::true_type { }; -} // namespace system -} // namespace boost - //] #endif From 0fbc46d5f01acb7189a8eef20cb85904cc87e39d Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:15:41 +0100 Subject: [PATCH 21/46] update comment --- example/3_advanced/http_server_cpp20/error.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/3_advanced/http_server_cpp20/error.cpp b/example/3_advanced/http_server_cpp20/error.cpp index da81aa834..6855ffc4e 100644 --- a/example/3_advanced/http_server_cpp20/error.cpp +++ b/example/3_advanced/http_server_cpp20/error.cpp @@ -28,7 +28,7 @@ const char* error_to_string(orders::errc value) } } -// Custom category for orders::errc +// The category to be returned by get_orders_category class orders_category final : public boost::system::error_category { public: From 4e077619f672d7ea8f5fa1842a85c556db566d4c Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:19:08 +0100 Subject: [PATCH 22/46] Include trimming --- example/3_advanced/http_server_cpp20/handle_request.hpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.hpp b/example/3_advanced/http_server_cpp20/handle_request.hpp index 5add3e3b0..99a123473 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.hpp +++ b/example/3_advanced/http_server_cpp20/handle_request.hpp @@ -16,8 +16,6 @@ #include #include -#include -#include #include #include From f64660c87bb111e253944c3e161a9231ad61ce28 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:19:13 +0100 Subject: [PATCH 23/46] Fix build error --- example/3_advanced/http_server_cpp20/repository.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index ee840978c..16b2748cd 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -151,7 +151,12 @@ asio::awaitable db_repository::create_order() conn.return_without_reset(); // This must always yield one row. Return it. - co_return result.rows<2>().front(); + const order& ord = result.rows<2>().front(); + co_return order_with_items{ + ord.id, + ord.status, + {} // A newly created order never has items + }; } asio::awaitable> db_repository::add_order_item( From 2770c85da531fc83482d86832aa608178fbb616d Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 11:25:41 +0100 Subject: [PATCH 24/46] split db_setup --- .../3_advanced/http_server_cpp20/db_setup.sql | 52 +++ example/3_advanced/http_server_cpp20/main.cpp | 2 +- .../http_server_cpp20/repository.cpp | 2 +- example/db_setup.sql | 299 ------------------ tools/ci/ci_util/db_setup.py | 1 + 5 files changed, 55 insertions(+), 301 deletions(-) create mode 100644 example/3_advanced/http_server_cpp20/db_setup.sql diff --git a/example/3_advanced/http_server_cpp20/db_setup.sql b/example/3_advanced/http_server_cpp20/db_setup.sql new file mode 100644 index 000000000..8227d8e82 --- /dev/null +++ b/example/3_advanced/http_server_cpp20/db_setup.sql @@ -0,0 +1,52 @@ +-- +-- Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +-- +-- Distributed under the Boost Software License, Version 1.0. (See accompanying +-- file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +-- + +-- Connection system variables +SET NAMES utf8; + +-- Database +DROP DATABASE IF EXISTS boost_mysql_orders; +CREATE DATABASE boost_mysql_orders; +USE boost_mysql_orders; + +-- User +DROP USER IF EXISTS 'orders_user'@'%'; +CREATE USER 'orders_user'@'%' IDENTIFIED BY 'orders_password'; +GRANT ALL PRIVILEGES ON boost_mysql_orders.* TO 'orders_user'@'%'; +FLUSH PRIVILEGES; + +-- Tables +CREATE TABLE products ( + id INT PRIMARY KEY AUTO_INCREMENT, + short_name VARCHAR(100) NOT NULL, + descr TEXT, + price INT NOT NULL, + FULLTEXT(short_name, descr) +); + +CREATE TABLE orders( + id INT PRIMARY KEY AUTO_INCREMENT, + `status` ENUM('draft', 'pending_payment', 'complete') NOT NULL DEFAULT 'draft' +); + +CREATE TABLE order_items( + id INT PRIMARY KEY AUTO_INCREMENT, + order_id INT NOT NULL, + product_id INT NOT NULL, + quantity INT NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + +-- Contents for the products table +INSERT INTO products (price, short_name, descr) VALUES + (6400, 'A Feast for Odin', 'A Feast for Odin is a points-driven game, with plethora of pathways to victory, with a range of risk balanced against reward. A significant portion of this is your central hall, which has a whopping -86 points of squares and a major part of your game is attempting to cover these up with various tiles. Likewise, long halls and island colonies can also offer large rewards, but they will have penalties of their own.'), + (1600, 'Railroad Ink', 'The critically acclaimed roll and write game where you draw routes on your board trying to connect the exits at its edges. The more you connect, the more points you make, but beware: each incomplete route will make you lose points!'), + (4000, 'Catan', 'Catan is a board game for two to four players in which you compete to gather resources and build the biggest settlements on the fictional island of Catan. It takes approximately one hour to play.'), + (2500, 'Not Alone', 'It is the 25th century. You are a member of an intergalactic expedition shipwrecked on a mysterious planet named Artemia. While waiting for the rescue ship, you begin to explore the planet but an alien entity picks up your scent and begins to hunt you. You are NOT ALONE! Will you survive the dangers of Artemia?'), + (4500, 'Dice Hospital', "In Dice Hospital, a worker placement board game, players are tasked with running a local hospital. Each round you'll be admitting new patients, hiring specialists, building new departments, and treating as many incoming patients as you can.") +; diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp index 2854af93b..829fad061 100644 --- a/example/3_advanced/http_server_cpp20/main.cpp +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -100,7 +100,7 @@ int main_impl(int argc, char* argv[]) .password = mysql_password, // Database to use when connecting - .database = "boost_mysql_examples", + .database = "boost_mysql_orders", // Using thread_safe will make the pool thread-safe by internally // creating and using a strand. diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 16b2748cd..4f23780b3 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -13,7 +13,7 @@ // // File: repository.cpp // -// See (TODO: link this) for the table definitions +// See the db_setup.sql file in this folder for the table definitions #include #include diff --git a/example/db_setup.sql b/example/db_setup.sql index 2becb7ad8..f360ac38d 100644 --- a/example/db_setup.sql +++ b/example/db_setup.sql @@ -86,302 +86,3 @@ CREATE USER 'example_user'@'%' IDENTIFIED WITH 'mysql_native_password'; ALTER USER 'example_user'@'%' IDENTIFIED BY 'example_password'; GRANT ALL PRIVILEGES ON boost_mysql_examples.* TO 'example_user'@'%'; FLUSH PRIVILEGES; - - --- --- Orders examples --- -CREATE TABLE products ( - id INT PRIMARY KEY AUTO_INCREMENT, - short_name VARCHAR(100) NOT NULL, - descr TEXT, - price INT NOT NULL, - FULLTEXT(short_name, descr) -); - -CREATE TABLE orders( - id INT PRIMARY KEY AUTO_INCREMENT, - `status` ENUM('draft', 'pending_payment', 'complete') NOT NULL DEFAULT 'draft' -); - -CREATE TABLE order_items( - id INT PRIMARY KEY AUTO_INCREMENT, - order_id INT NOT NULL, - product_id INT NOT NULL, - quantity INT NOT NULL, - FOREIGN KEY (order_id) REFERENCES orders(id), - FOREIGN KEY (product_id) REFERENCES products(id) -); - --- Procedures for the orders examples -DELIMITER // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_products(IN p_search VARCHAR(50)) -BEGIN - DECLARE max_products INT DEFAULT 20; - IF p_search IS NULL THEN - SELECT id, short_name, descr, price - FROM products - LIMIT max_products; - ELSE - SELECT id, short_name, descr, price FROM products - WHERE MATCH(short_name, descr) AGAINST(p_search) - LIMIT max_products; - END IF; -END // - -CREATE PROCEDURE create_order() -BEGIN - START TRANSACTION; - - -- Create the order - INSERT INTO orders () VALUES (); - - -- Return the order - SELECT id, `status` - FROM orders - WHERE id = LAST_INSERT_ID(); - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_order( - IN p_order_id INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION READ ONLY; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'get_order: invalid parameters'; - END IF; - - -- Check that the order exists - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - - -- Return the order. The IFNULL statements make MySQL correctly report the fields as non-NULL - SELECT - IFNULL(p_order_id, 0) AS id, - IFNULL(order_status, 'draft') AS `status`; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE get_orders() -BEGIN - SELECT id, `status` FROM orders; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE add_line_item( - IN p_order_id INT, - IN p_product_id INT, - IN p_quantity INT, - OUT pout_line_item_id INT -) -BEGIN - DECLARE product_price INT; - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL OR p_product_id IS NULL OR p_quantity IS NULL OR p_quantity <= 0 THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'add_line_item: invalid params'; - END IF; - - -- Ensure that the product is valid - SELECT price INTO product_price FROM products WHERE id = p_product_id; - IF product_price IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given product does not exist'; - END IF; - - -- Get the order - SELECT `status` INTO order_status FROM orders WHERE id = p_order_id; - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not editable'; - END IF; - - -- Insert the new item - INSERT INTO order_items (order_id, product_id, quantity) VALUES (p_order_id, p_product_id, p_quantity); - - -- Return value - SET pout_line_item_id = LAST_INSERT_ID(); - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE remove_line_item( - IN p_line_item_id INT -) -BEGIN - DECLARE order_id INT; - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_line_item_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'remove_line_item: invalid params'; - END IF; - - -- Get the order - SELECT orders.id, orders.`status` - INTO order_id, order_status - FROM orders - JOIN order_items items ON (orders.id = items.order_id) - WHERE items.id = p_line_item_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order item does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not editable'; - END IF; - - -- Delete the line item - DELETE FROM order_items - WHERE id = p_line_item_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = order_id; - - COMMIT; -END // - -CREATE DEFINER = 'example_user'@'%' PROCEDURE checkout_order( - IN p_order_id INT, - OUT pout_order_total INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'checkout_order: invalid params'; - END IF; - - -- Get the order - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'draft' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not in a state that can be checked out'; - END IF; - - -- Update the order - UPDATE orders SET `status` = 'pending_payment' WHERE id = p_order_id; - - -- Retrieve the total price - SELECT SUM(prod.price * item.quantity) - INTO pout_order_total - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - - -CREATE DEFINER = 'example_user'@'%' PROCEDURE complete_order( - IN p_order_id INT -) -BEGIN - DECLARE order_status TEXT; - START TRANSACTION; - - -- Check parameters - IF p_order_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1048, MESSAGE_TEXT = 'complete_order: invalid params'; - END IF; - - -- Get the order - SELECT `status` - INTO order_status - FROM orders WHERE id = p_order_id; - - IF order_status IS NULL THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1329, MESSAGE_TEXT = 'The given order does not exist'; - END IF; - IF order_status <> 'pending_payment' THEN - SIGNAL SQLSTATE '45000' SET MYSQL_ERRNO = 1000, MESSAGE_TEXT = 'The given order is not in a state that can be completed'; - END IF; - - -- Update the order - UPDATE orders SET `status` = 'complete' WHERE id = p_order_id; - - -- Return the edited order - SELECT id, `status` - FROM orders WHERE id = p_order_id; - SELECT - item.id AS id, - item.quantity AS quantity, - prod.price AS unit_price - FROM order_items item - JOIN products prod ON item.product_id = prod.id - WHERE item.order_id = p_order_id; - - COMMIT; -END // - -DELIMITER ; - --- Create an order, at least -INSERT INTO orders () VALUES (); - --- Contents for the products table -INSERT INTO products (price, short_name, descr) VALUES - (6400, 'A Feast for Odin', 'A Feast for Odin is a points-driven game, with plethora of pathways to victory, with a range of risk balanced against reward. A significant portion of this is your central hall, which has a whopping -86 points of squares and a major part of your game is attempting to cover these up with various tiles. Likewise, long halls and island colonies can also offer large rewards, but they will have penalties of their own.'), - (1600, 'Railroad Ink', 'The critically acclaimed roll and write game where you draw routes on your board trying to connect the exits at its edges. The more you connect, the more points you make, but beware: each incomplete route will make you lose points!'), - (4000, 'Catan', 'Catan is a board game for two to four players in which you compete to gather resources and build the biggest settlements on the fictional island of Catan. It takes approximately one hour to play.'), - (2500, 'Not Alone', 'It is the 25th century. You are a member of an intergalactic expedition shipwrecked on a mysterious planet named Artemia. While waiting for the rescue ship, you begin to explore the planet but an alien entity picks up your scent and begins to hunt you. You are NOT ALONE! Will you survive the dangers of Artemia?'), - (4500, 'Dice Hospital', "In Dice Hospital, a worker placement board game, players are tasked with running a local hospital. Each round you'll be admitting new patients, hiring specialists, building new departments, and treating as many incoming patients as you can.") -; diff --git a/tools/ci/ci_util/db_setup.py b/tools/ci/ci_util/db_setup.py index 70457bf18..eb794020d 100644 --- a/tools/ci/ci_util/db_setup.py +++ b/tools/ci/ci_util/db_setup.py @@ -92,6 +92,7 @@ def db_setup( # Source files _run_sql_file(source_dir.joinpath('example', 'db_setup.sql')) + _run_sql_file(source_dir.joinpath('example', '3_advanced', 'http_server_cpp20', 'db_setup.sql')) _run_sql_file(source_dir.joinpath('test', 'integration', 'db_setup.sql')) if not disabled_features['sha256']: _run_sql_file(source_dir.joinpath('test', 'integration', 'db_setup_sha256.sql')) From ec6bf817efd97816b5bbabd98c22034b32cc2035 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:22:06 +0100 Subject: [PATCH 25/46] Fix static iface failure --- example/3_advanced/http_server_cpp20/types.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/3_advanced/http_server_cpp20/types.hpp b/example/3_advanced/http_server_cpp20/types.hpp index 6288594c2..db609cadb 100644 --- a/example/3_advanced/http_server_cpp20/types.hpp +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -38,7 +39,7 @@ struct product std::string short_name; // The product's description - std::string descr; + std::optional descr; // The product's price, in dollar cents std::int64_t price; From 885e239f3f5022f341516cd45cf5c1a062357168 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:22:25 +0100 Subject: [PATCH 26/46] run_orders prototype --- example/private/run_orders.py | 179 ++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 example/private/run_orders.py diff --git a/example/private/run_orders.py b/example/private/run_orders.py new file mode 100644 index 000000000..dcdd66761 --- /dev/null +++ b/example/private/run_orders.py @@ -0,0 +1,179 @@ +#!/usr/bin/python3 +# +# Copyright (c) 2019-2024 Ruben Perez Hidalgo (rubenperez038 at gmail dot com) +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# + +import requests +import argparse +from subprocess import PIPE, STDOUT, Popen +from contextlib import contextmanager +import re +import os +import unittest + +_is_win = os.name == 'nt' + + +def _check_response(res: requests.Response): + if res.status_code >= 400: + print(res.text) + res.raise_for_status() + + +# Returns the port the server is listening at +def _parse_server_start_line(line: str) -> int: + m = re.match(r'Server listening at 0\.0\.0\.0:([0-9]+)', line) + if m is None: + raise RuntimeError('Unexpected server start line') + return int(m.group(1)) + + +@contextmanager +def _launch_server(exe: str, host: str): + # Launch server and let it choose a free port for us. + # This prevents port clashes during b2 parallel test runs + server = Popen([exe, 'orders_user', 'orders_password', host, '0'], stdout=PIPE, stderr=STDOUT) + assert server.stdout is not None + with server: + try: + # Wait until the server is ready + ready_line = server.stdout.readline().decode() + print(ready_line, end='', flush=True) + if ready_line.startswith('Sorry'): # C++20 unsupported, skip the test + exit(0) + yield _parse_server_start_line(ready_line) + finally: + print('Terminating server...', flush=True) + + # In Windows, there is no sane way to cleanly terminate the process. + # Sending a Ctrl-C terminates all process attached to the console (including ourselves + # and any parent test runner). Running the process in a separate terminal doesn't allow + # access to stdout, which is problematic, too. + if _is_win: + # kill is an alias for TerminateProcess with the given exit code + os.kill(server.pid, 9999) + else: + # Send SIGTERM + server.terminate() + + # Print any output the process generated + print('Server stdout: \n', server.stdout.read().decode(), flush=True) + + # Verify that it exited gracefully + if (_is_win and server.returncode != 9999) or (not _is_win and server.returncode): + raise RuntimeError('Server did not exit cleanly. retcode={}'.format(server.returncode)) + + +class TestOrders(unittest.TestCase): + + def __init__(self, method_name: str, port: int) -> None: + super().__init__(method_name) + self._base_url = 'http://127.0.0.1:{}'.format(port) + + + def test_search_products(self) -> None: + res = requests.get( + '{}/products?search=odin'.format(self._base_url) + ) + _check_response(res) + products = res.json() + self.assertNotEqual(len(products), 0) + odin = products[0] + self.assertIsInstance(odin['id'], int) + self.assertEqual(odin['short_name'], 'A Feast for Odin') + self.assertEqual(odin['price'], 6400) + self.assertIsInstance(odin['descr'], str) + + +# def _call_endpoints(port: int): +# base_url = 'http://127.0.0.1:{}'.format(port) + +# # Search products +# # List orders (empty) +# # Create an order +# # Retrieve the order +# # Add some line items +# # Retrieve the order +# # Remove a line item +# # Checkout the order +# # Complete the order + +# # Get an order that doesn't exist +# # Add a line item to an order that doesn't exist +# # Add a line item for an order that doesn't exist + +# # Create a note +# note_unique = _random_string() +# title = 'My note {}'.format(note_unique) +# content = 'This is a note about {}'.format(note_unique) +# res = requests.post( +# '{}/notes'.format(base_url), +# json={'title': title, 'content': content} +# ) +# _check_response(res) +# note = res.json() +# note_id = int(note['note']['id']) +# assert note['note']['title'] == title +# assert note['note']['content'] == content + +# # Retrieve all notes +# res = requests.get('{}/notes'.format(base_url)) +# _check_response(res) +# all_notes = res.json() +# assert len([n for n in all_notes['notes'] if n['id'] == note_id]) == 1 + +# # Edit the note +# note_unique = _random_string() +# title = 'Edited {}'.format(note_unique) +# content = 'This is a note an edit on {}'.format(note_unique) +# res = requests.put( +# '{}/notes/{}'.format(base_url, note_id), +# json={'title': title, 'content': content} +# ) +# _check_response(res) +# note = res.json() +# assert int(note['note']['id']) == note_id +# assert note['note']['title'] == title +# assert note['note']['content'] == content + +# # Retrieve the note +# res = requests.get('{}/notes/{}'.format(base_url, note_id)) +# _check_response(res) +# note = res.json() +# assert int(note['note']['id']) == note_id +# assert note['note']['title'] == title +# assert note['note']['content'] == content + +# # Delete the note +# res = requests.delete('{}/notes/{}'.format(base_url, note_id)) +# _check_response(res) +# assert res.json()['deleted'] == True + +# # The note is not there +# res = requests.get('{}/notes/{}'.format(base_url, note_id)) +# assert res.status_code == 404 + + +def main(): + # Parse command line arguments + parser = argparse.ArgumentParser() + parser.add_argument('executable') + parser.add_argument('host') + args = parser.parse_args() + + # Launch the server + with _launch_server(args.executable, args.host) as listening_port: + tests = [ + TestOrders(method, listening_port) + for method in unittest.defaultTestLoader.getTestCaseNames(TestOrders) + ] + suite = unittest.TestSuite() + suite.addTests(tests) + unittest.TextTestRunner().run(suite) + + +if __name__ == '__main__': + main() From 2a912dcdd7f38d47704d909e2aa19900224dac26 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:33:30 +0100 Subject: [PATCH 27/46] Error case and helpers --- example/private/run_orders.py | 42 ++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index dcdd66761..8008f0bb2 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -17,12 +17,6 @@ _is_win = os.name == 'nt' -def _check_response(res: requests.Response): - if res.status_code >= 400: - print(res.text) - res.raise_for_status() - - # Returns the port the server is listening at def _parse_server_start_line(line: str) -> int: m = re.match(r'Server listening at 0\.0\.0\.0:([0-9]+)', line) @@ -73,19 +67,41 @@ def __init__(self, method_name: str, port: int) -> None: super().__init__(method_name) self._base_url = 'http://127.0.0.1:{}'.format(port) + @staticmethod + def _json_response(res: requests.Response) -> dict: + if res.status_code >= 400: + print(res.text) + res.raise_for_status() + return res.json() + + + def _check_error(self, res: requests.Response, expected_status: int) -> None: + self.assertEqual(res.status_code, expected_status) + + + def _request(self, method: str, url: str, **kwargs) -> requests.Response: + return requests.request(method=method, url=self._base_url + url, **kwargs) + + + def _request_as_json(self, method: str, url: str, **kwargs) -> dict: + return self._json_response(self._request(method, url, **kwargs)) + def test_search_products(self) -> None: - res = requests.get( - '{}/products?search=odin'.format(self._base_url) - ) - _check_response(res) - products = res.json() - self.assertNotEqual(len(products), 0) + # Issue the request + products = self._request_as_json('get', '/products', params={'search': 'odin'}) + + # Check + self.assertNotEqual(len(products), 0) # At least one product odin = products[0] - self.assertIsInstance(odin['id'], int) + self.assertIsInstance(odin['id'], int) # We don't know the exact ID self.assertEqual(odin['short_name'], 'A Feast for Odin') self.assertEqual(odin['price'], 6400) self.assertIsInstance(odin['descr'], str) + + + def test_search_products_missing_param(self) -> None: + self._check_error(self._request('get', '/products'), 400) # def _call_endpoints(port: int): From 3ba826b07d0689c9a0037909fb6eee2fd9243895 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:45:57 +0100 Subject: [PATCH 28/46] Missing multi_queries --- example/3_advanced/http_server_cpp20/main.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/example/3_advanced/http_server_cpp20/main.cpp b/example/3_advanced/http_server_cpp20/main.cpp index 829fad061..f12f57a77 100644 --- a/example/3_advanced/http_server_cpp20/main.cpp +++ b/example/3_advanced/http_server_cpp20/main.cpp @@ -102,6 +102,9 @@ int main_impl(int argc, char* argv[]) // Database to use when connecting .database = "boost_mysql_orders", + // We're using multi-queries + .multi_queries = true, + // Using thread_safe will make the pool thread-safe by internally // creating and using a strand. // This allows us to share the pool between sessions, which may run From ceddea458773889bf5c1957afaf1dcc220c5a956 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:46:06 +0100 Subject: [PATCH 29/46] Order lifecycle test --- example/private/run_orders.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 8008f0bb2..f27a80cc5 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -13,6 +13,7 @@ import re import os import unittest +import copy _is_win = os.name == 'nt' @@ -102,6 +103,49 @@ def test_search_products(self) -> None: def test_search_products_missing_param(self) -> None: self._check_error(self._request('get', '/products'), 400) + + + def test_order_lifecycle(self) -> None: + # Create an order + order = self._request_as_json('post', '/orders') + order_id: int = order['id'] + self.assertIsInstance(order_id, int) + self.assertEqual(order['status'], 'draft') + self.assertEqual(order['items'], []) + + # Add an item + order = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + }) + items = order['items'] + self.assertEqual(order['id'], order_id) + self.assertEqual(order['status'], 'draft') + self.assertEqual(len(order['items']), 1) + self.assertIsInstance(items[0]['id'], int) + self.assertEqual(items[0]['product_id'], 2) + self.assertEqual(items[0]['quantity'], 20) + + # Checkout + expected_order = copy.deepcopy(order) + expected_order['status'] = 'pending_payment' + order = self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + self.assertEqual(order, expected_order) + + # Complete + expected_order = copy.deepcopy(order) + expected_order['status'] = 'complete' + order = self._request_as_json('post', '/orders/complete', params={'id': order_id}) + self.assertEqual(order, expected_order) + + + + + + # Method not allowed + # Endpoint not found + # Invalid content-type # def _call_endpoints(port: int): From 55209becca56a8f30820a8a18b33f66292a9ec94 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:56:28 +0100 Subject: [PATCH 30/46] remove items test --- example/private/run_orders.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index f27a80cc5..fa811f151 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -140,6 +140,39 @@ def test_order_lifecycle(self) -> None: self.assertEqual(order, expected_order) + def test_remove_items(self) -> None: + # Create an order + order1 = self._request_as_json('post', '/orders') + order_id: int = order1['id'] + + # Create two items + self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + }) + order2 = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }) + order2_items = order2['items'] + + # Sanity check + self.assertEqual(order2['id'], order_id) + self.assertEqual(order2['status'], 'draft') + self.assertEqual(len(order2_items), 2) + product_ids = list(set(item['product_id'] for item in order2_items)) + self.assertEqual(product_ids, [1, 2]) # IDs 1 and 2 in any order + + # Delete one of the items + order3 = self._request_as_json('delete', '/orders/items', params={'id': order2_items[0]['id']}) + self.assertEqual(order3['id'], order_id) + self.assertEqual(order3['status'], 'draft') + self.assertEqual(order3['items'], [order2_items[1]]) + + + From d76963fb4b7492248025065c72eacacf7735176d Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 12:58:31 +0100 Subject: [PATCH 31/46] test get orders --- example/private/run_orders.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index fa811f151..d670e0756 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -69,7 +69,7 @@ def __init__(self, method_name: str, port: int) -> None: self._base_url = 'http://127.0.0.1:{}'.format(port) @staticmethod - def _json_response(res: requests.Response) -> dict: + def _json_response(res: requests.Response): if res.status_code >= 400: print(res.text) res.raise_for_status() @@ -84,7 +84,7 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response: return requests.request(method=method, url=self._base_url + url, **kwargs) - def _request_as_json(self, method: str, url: str, **kwargs) -> dict: + def _request_as_json(self, method: str, url: str, **kwargs): return self._json_response(self._request(method, url, **kwargs)) @@ -170,6 +170,13 @@ def test_remove_items(self) -> None: self.assertEqual(order3['id'], order_id) self.assertEqual(order3['status'], 'draft') self.assertEqual(order3['items'], [order2_items[1]]) + + + def test_get_orders(self) -> None: + orders = self._request_as_json('get', '/orders') + self.assertIsInstance(orders, list) + + From a0a0dc295d4b2f29c73fe45b5969c9e091572cc7 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:01:01 +0100 Subject: [PATCH 32/46] get order by id test --- example/private/run_orders.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index d670e0756..0647627ba 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -176,7 +176,19 @@ def test_get_orders(self) -> None: orders = self._request_as_json('get', '/orders') self.assertIsInstance(orders, list) - + + def test_get_single_order(self) -> None: + # Create an order and add an item + order = self._request_as_json('post', '/orders') + order = self._request_as_json('post', '/orders/items', json={ + 'order_id': order['id'], + 'product_id': 2, + 'quantity': 20 + }) + + # Retrieve the order by id + order2 = self._request_as_json('get', '/orders', params={'id': order['id']}) + self.assertEqual(order2, order) From 50c7a9bca7c14e8f46c9a7bab12fe2b32abf678b Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:06:50 +0100 Subject: [PATCH 33/46] Invalid ID for get_order --- example/private/run_orders.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 0647627ba..af6d6b66d 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -87,7 +87,9 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response: def _request_as_json(self, method: str, url: str, **kwargs): return self._json_response(self._request(method, url, **kwargs)) - + # + # Success cases + # def test_search_products(self) -> None: # Issue the request products = self._request_as_json('get', '/products', params={'search': 'odin'}) @@ -101,10 +103,6 @@ def test_search_products(self) -> None: self.assertIsInstance(odin['descr'], str) - def test_search_products_missing_param(self) -> None: - self._check_error(self._request('get', '/products'), 400) - - def test_order_lifecycle(self) -> None: # Create an order order = self._request_as_json('post', '/orders') @@ -189,6 +187,19 @@ def test_get_single_order(self) -> None: # Retrieve the order by id order2 = self._request_as_json('get', '/orders', params={'id': order['id']}) self.assertEqual(order2, order) + + + # + # Endpoints with malformed requests + # + + def test_search_products_missing_param(self) -> None: + self._check_error(self._request('get', '/products'), 400) + + + def test_get_order_invalid_id(self) -> None: + self._check_error(self._request('get', '/orders', params={'id': 'abc'}), 400) + From a54731f39ee39b529f8e1dca7a37a99bf800ff26 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:17:49 +0100 Subject: [PATCH 34/46] invalid content type --- example/private/run_orders.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index af6d6b66d..1b8ac230f 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -86,6 +86,10 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response: def _request_as_json(self, method: str, url: str, **kwargs): return self._json_response(self._request(method, url, **kwargs)) + + + def _request_error(self, method: str, url: str, expected_status: int, **kwargs): + return self._check_error(self._request(method, url, **kwargs), expected_status) # # Success cases @@ -194,13 +198,23 @@ def test_get_single_order(self) -> None: # def test_search_products_missing_param(self) -> None: - self._check_error(self._request('get', '/products'), 400) + self._request_error('get', '/products', expected_status=400) def test_get_order_invalid_id(self) -> None: - self._check_error(self._request('get', '/orders', params={'id': 'abc'}), 400) + self._request_error('get', '/orders', params={'id': 'abc'}, expected_status=400) + def test_add_order_item_invalid_content_type(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/items', headers={'Content-Type':'text/html'}, json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }, expected_status=400) From 8a0ab7bbeb9d82447089df94ce6c62eb5686e6e8 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:19:59 +0100 Subject: [PATCH 35/46] more error tests --- example/private/run_orders.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 1b8ac230f..1693ba5bb 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -203,6 +203,10 @@ def test_search_products_missing_param(self) -> None: def test_get_order_invalid_id(self) -> None: self._request_error('get', '/orders', params={'id': 'abc'}, expected_status=400) + + + def test_get_order_not_found(self) -> None: + self._request_error('get', '/orders', params={'id': 0xffffff}, expected_status=404) def test_add_order_item_invalid_content_type(self) -> None: @@ -217,6 +221,18 @@ def test_add_order_item_invalid_content_type(self) -> None: }, expected_status=400) + def test_add_order_item_invalid_json(self) -> None: + self._request_error('post', '/orders/items', headers={'Content-Type':'application/json'}, + data='bad', expected_status=400) + + + def test_add_order_item_invalid_json_keys(self) -> None: + self._request_error('post', '/orders/items', json={ + 'product_id': 1, + 'quantity': 1 + }, expected_status=400) + + From 869b7f259202567555e7c566c2434c3846b50c78 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:23:19 +0100 Subject: [PATCH 36/46] add order item not found --- example/private/run_orders.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 1693ba5bb..6659bd713 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -194,7 +194,7 @@ def test_get_single_order(self) -> None: # - # Endpoints with malformed requests + # Endpoint errors # def test_search_products_missing_param(self) -> None: @@ -228,9 +228,33 @@ def test_add_order_item_invalid_json(self) -> None: def test_add_order_item_invalid_json_keys(self) -> None: self._request_error('post', '/orders/items', json={ + 'order_id': '1', 'product_id': 1, 'quantity': 1 }, expected_status=400) + + + def test_add_order_item_order_not_found(self) -> None: + self._request_error('post', '/orders/items', json={ + 'order_id': 0xffffffff, + 'product_id': 1, + 'quantity': 1 + }, expected_status=404) + + + def test_add_order_item_product_not_found(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 0xffffffff, + 'quantity': 1 + }, expected_status=400) + + + From f74745892f2462f168ccbfbfd4facfa572032bb2 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:26:26 +0100 Subject: [PATCH 37/46] switch to unprocessable entity --- .../3_advanced/http_server_cpp20/handle_request.cpp | 12 ++++++++++-- example/private/run_orders.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index a4ddf062a..ef8d38b8f 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -125,6 +125,11 @@ http::response not_found(std::string body = "The requested re return error_response(http::status::not_found, std::move(body)); } +http::response unprocessable_entity(std::string body) +{ + return error_response(http::status::unprocessable_entity, std::move(body)); +} + http::response internal_server_error() { return error_response(http::status::internal_server_error, {}); @@ -175,9 +180,12 @@ http::response response_from_db_error(boost::system::error_co switch (static_cast(ec.value())) { case orders::errc::not_found: return not_found("The referenced entity does not exist"); - case orders::errc::product_not_found: return bad_request("The referenced product does not exist"); + case orders::errc::product_not_found: + return unprocessable_entity("The referenced product does not exist"); case orders::errc::order_invalid_status: - return bad_request("The referenced order doesn't have the status required by the operation"); + return unprocessable_entity( + "The referenced order doesn't have the status required by the operation" + ); default: return internal_server_error(); } } diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 6659bd713..a6aba5b18 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -251,7 +251,7 @@ def test_add_order_item_product_not_found(self) -> None: 'order_id': order_id, 'product_id': 0xffffffff, 'quantity': 1 - }, expected_status=400) + }, expected_status=422) From e43758cf0092e746e1fa7b7af9fa12bad2d2c81e Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:27:37 +0100 Subject: [PATCH 38/46] order not editable --- example/private/run_orders.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index a6aba5b18..642ed6a62 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -254,6 +254,19 @@ def test_add_order_item_product_not_found(self) -> None: }, expected_status=422) + def test_add_order_item_order_not_editable(self) -> None: + # Create an order and check it out + order_id = self._request_as_json('post', '/orders')['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 1, + 'quantity': 1 + }, expected_status=422) + + From c399107eef13e29d3b90c6f21b6469c4a3ba7ff0 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:33:06 +0100 Subject: [PATCH 39/46] remove order item errors --- example/private/run_orders.py | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index 642ed6a62..cf486715a 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -194,13 +194,15 @@ def test_get_single_order(self) -> None: # - # Endpoint errors + # Search products errors # - def test_search_products_missing_param(self) -> None: self._request_error('get', '/products', expected_status=400) + # + # Get order errors + # def test_get_order_invalid_id(self) -> None: self._request_error('get', '/orders', params={'id': 'abc'}, expected_status=400) @@ -209,6 +211,9 @@ def test_get_order_not_found(self) -> None: self._request_error('get', '/orders', params={'id': 0xffffff}, expected_status=404) + # + # Add order item errors + # def test_add_order_item_invalid_content_type(self) -> None: # Create an order order_id = self._request_as_json('post', '/orders')['id'] @@ -265,12 +270,44 @@ def test_add_order_item_order_not_editable(self) -> None: 'product_id': 1, 'quantity': 1 }, expected_status=422) + + # + # Remove order item errors + # + def test_remove_order_item_missing_id(self) -> None: + self._request_error('delete', '/orders/items', expected_status=400) + + + def test_remove_order_item_invalid_id(self) -> None: + self._request_error('delete', '/orders/items', params={'id': 'abc'}, expected_status=400) + + + def test_remove_order_item_not_found(self) -> None: + self._request_error('delete', '/orders/items', params={'id': 0xffffffff}, expected_status=404) + + + def test_remove_order_item_order_not_editable(self) -> None: + # Create an order with an item and check it out + order_id = self._request_as_json('post', '/orders')['id'] + item_id = self._request_as_json('post', '/orders/items', json={ + 'order_id': order_id, + 'product_id': 2, + 'quantity': 20 + })['items'][0]['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('delete', '/orders/items', params={'id': item_id}, expected_status=422) + + + + # Method not allowed From 48304c3e92af2653511dc51752b73fa84a83db8a Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:36:20 +0100 Subject: [PATCH 40/46] checkout/complete order tests --- example/private/run_orders.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index cf486715a..a0abd4e98 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -300,6 +300,51 @@ def test_remove_order_item_order_not_editable(self) -> None: self._request_error('delete', '/orders/items', params={'id': item_id}, expected_status=422) + # + # Checkout order errors + # + def test_checkout_order_missing_id(self) -> None: + self._request_error('post', '/orders/checkout', expected_status=400) + + + def test_checkout_order_invalid_id(self) -> None: + self._request_error('post', '/orders/checkout', params={'id': 'abc'}, expected_status=400) + + + def test_checkout_order_not_found(self) -> None: + self._request_error('post', '/orders/checkout', params={'id': 0xffffffff}, expected_status=404) + + + def test_checkout_order_not_editable(self) -> None: + # Create an order and check it out + order_id = self._request_as_json('post', '/orders')['id'] + self._request_as_json('post', '/orders/checkout', params={'id': order_id}) + + # Check the error + self._request_error('post', '/orders/checkout', params={'id': order_id}, expected_status=422) + + + # + # Cmplete order errors + # + def test_complete_order_missing_id(self) -> None: + self._request_error('post', '/orders/complete', expected_status=400) + + + def test_complete_order_invalid_id(self) -> None: + self._request_error('post', '/orders/complete', params={'id': 'abc'}, expected_status=400) + + + def test_complete_order_not_found(self) -> None: + self._request_error('post', '/orders/complete', params={'id': 0xffffffff}, expected_status=404) + + + def test_complete_order_not_editable(self) -> None: + # Create an order + order_id = self._request_as_json('post', '/orders')['id'] + + # Check the error + self._request_error('post', '/orders/complete', params={'id': order_id}, expected_status=422) From 0f606559a693d898335dd20713703d4783477d58 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:38:41 +0100 Subject: [PATCH 41/46] Finished tests --- example/private/run_orders.py | 92 +++++------------------------------ 1 file changed, 12 insertions(+), 80 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index a0abd4e98..bc7b49a87 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -15,6 +15,7 @@ import unittest import copy + _is_win = os.name == 'nt' @@ -325,7 +326,7 @@ def test_checkout_order_not_editable(self) -> None: # - # Cmplete order errors + # Complete order errors # def test_complete_order_missing_id(self) -> None: self._request_error('post', '/orders/complete', expected_status=400) @@ -345,89 +346,20 @@ def test_complete_order_not_editable(self) -> None: # Check the error self._request_error('post', '/orders/complete', params={'id': order_id}, expected_status=422) - - - - - - + # + # Generic errors + # + + def test_endpoint_not_found(self) -> None: + self._request_error('get', '/orders/other', expected_status=404) + self._request_error('get', '/orders_other', expected_status=404) - # Method not allowed - # Endpoint not found - # Invalid content-type - - -# def _call_endpoints(port: int): -# base_url = 'http://127.0.0.1:{}'.format(port) - -# # Search products -# # List orders (empty) -# # Create an order -# # Retrieve the order -# # Add some line items -# # Retrieve the order -# # Remove a line item -# # Checkout the order -# # Complete the order - -# # Get an order that doesn't exist -# # Add a line item to an order that doesn't exist -# # Add a line item for an order that doesn't exist - -# # Create a note -# note_unique = _random_string() -# title = 'My note {}'.format(note_unique) -# content = 'This is a note about {}'.format(note_unique) -# res = requests.post( -# '{}/notes'.format(base_url), -# json={'title': title, 'content': content} -# ) -# _check_response(res) -# note = res.json() -# note_id = int(note['note']['id']) -# assert note['note']['title'] == title -# assert note['note']['content'] == content - -# # Retrieve all notes -# res = requests.get('{}/notes'.format(base_url)) -# _check_response(res) -# all_notes = res.json() -# assert len([n for n in all_notes['notes'] if n['id'] == note_id]) == 1 - -# # Edit the note -# note_unique = _random_string() -# title = 'Edited {}'.format(note_unique) -# content = 'This is a note an edit on {}'.format(note_unique) -# res = requests.put( -# '{}/notes/{}'.format(base_url, note_id), -# json={'title': title, 'content': content} -# ) -# _check_response(res) -# note = res.json() -# assert int(note['note']['id']) == note_id -# assert note['note']['title'] == title -# assert note['note']['content'] == content - -# # Retrieve the note -# res = requests.get('{}/notes/{}'.format(base_url, note_id)) -# _check_response(res) -# note = res.json() -# assert int(note['note']['id']) == note_id -# assert note['note']['title'] == title -# assert note['note']['content'] == content - -# # Delete the note -# res = requests.delete('{}/notes/{}'.format(base_url, note_id)) -# _check_response(res) -# assert res.json()['deleted'] == True - -# # The note is not there -# res = requests.get('{}/notes/{}'.format(base_url, note_id)) -# assert res.status_code == 404 - + def test_method_not_allowed(self) -> None: + self._request_error('delete', '/orders', expected_status=405) + def main(): # Parse command line arguments From b3797643b1cb9f55317eddb38781252724374dfe Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:38:54 +0100 Subject: [PATCH 42/46] runner to cmake --- example/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt index ba5cbf137..35afa16b4 100644 --- a/example/CMakeLists.txt +++ b/example/CMakeLists.txt @@ -127,6 +127,6 @@ add_example( Boost::url Boost::beast Boost::pfr - PYTHON_RUNNER run_connection_pool.py # TODO + PYTHON_RUNNER run_orders.py ARGS ${SERVER_HOST} ) From 8793f30af47822a765eb242bf5566a5fd0efe4ab Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:53:49 +0100 Subject: [PATCH 43/46] runner cleanup --- example/private/run_orders.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/example/private/run_orders.py b/example/private/run_orders.py index bc7b49a87..0c618ef49 100644 --- a/example/private/run_orders.py +++ b/example/private/run_orders.py @@ -14,6 +14,7 @@ import os import unittest import copy +import sys _is_win = os.name == 'nt' @@ -64,10 +65,11 @@ def _launch_server(exe: str, host: str): class TestOrders(unittest.TestCase): + _port = -1 - def __init__(self, method_name: str, port: int) -> None: - super().__init__(method_name) - self._base_url = 'http://127.0.0.1:{}'.format(port) + @property + def _base_url(self) -> str: + return 'http://127.0.0.1:{}'.format(self._port) @staticmethod def _json_response(res: requests.Response): @@ -370,13 +372,8 @@ def main(): # Launch the server with _launch_server(args.executable, args.host) as listening_port: - tests = [ - TestOrders(method, listening_port) - for method in unittest.defaultTestLoader.getTestCaseNames(TestOrders) - ] - suite = unittest.TestSuite() - suite.addTests(tests) - unittest.TextTestRunner().run(suite) + TestOrders._port = listening_port + unittest.main(argv=[sys.argv[0]]) if __name__ == '__main__': From 7b58088138cda7e9679c80ee99726e5d8a671cc9 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 13:53:59 +0100 Subject: [PATCH 44/46] Bug in method_not_allowed --- example/3_advanced/http_server_cpp20/handle_request.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index ef8d38b8f..7f5e67d0e 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -385,7 +385,7 @@ asio::awaitable> orders::handle_request( auto it3 = std::find_if(it1, it2, [&request](const std::pair& ep) { return ep.second.method == request.method(); }); - if (it3 == endpoints.end()) + if (it3 == it2) co_return error_response(http::status::method_not_allowed, "Unsupported HTTP method"); // Compose the data struct (TODO) From 845f01b4d1f23b19137102f378158831de51f5c3 Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 14:02:34 +0100 Subject: [PATCH 45/46] Jamfile --- example/Jamfile | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/example/Jamfile b/example/Jamfile index 96422adfa..4775f435e 100644 --- a/example/Jamfile +++ b/example/Jamfile @@ -120,3 +120,16 @@ run_example connection_pool : norecover:no enable:no ; + +run_example http_server_cpp20 : + 3_advanced/http_server_cpp20/main.cpp + 3_advanced/http_server_cpp20/repository.cpp + 3_advanced/http_server_cpp20/handle_request.cpp + 3_advanced/http_server_cpp20/server.cpp + 3_advanced/http_server_cpp20/error.cpp + /boost/mysql/test//boost_json_lib + /boost/url//boost_url + /boost/mysql/test//boost_beast_lib + : $(hostname) + : run_orders.py + ; From 0ba8b09da7ddba82545977976e55837bd8e6a8cc Mon Sep 17 00:00:00 2001 From: Ruben Perez Date: Mon, 16 Dec 2024 14:11:00 +0100 Subject: [PATCH 46/46] Add to qbks --- doc/qbk/00_main.qbk | 10 ++++++ doc/qbk/21_examples.qbk | 31 ++++++++++++++++++- .../http_server_cpp20/handle_request.cpp | 2 +- .../http_server_cpp20/handle_request.hpp | 2 +- .../http_server_cpp20/repository.cpp | 2 +- .../http_server_cpp20/repository.hpp | 2 +- .../3_advanced/http_server_cpp20/server.cpp | 2 +- .../3_advanced/http_server_cpp20/types.hpp | 2 +- tools/scripts/examples_qbk.py | 15 ++++++++- 9 files changed, 60 insertions(+), 8 deletions(-) diff --git a/doc/qbk/00_main.qbk b/doc/qbk/00_main.qbk index 520e2d3e9..2d3ce7c85 100644 --- a/doc/qbk/00_main.qbk +++ b/doc/qbk/00_main.qbk @@ -148,6 +148,16 @@ END [import ../../example/2_simple/patch_updates.cpp] [import ../../example/2_simple/source_script.cpp] [import ../../example/2_simple/pipeline.cpp] +[import ../../example/3_advanced/http_server_cpp20/main.cpp] +[import ../../example/3_advanced/http_server_cpp20/types.hpp] +[import ../../example/3_advanced/http_server_cpp20/error.hpp] +[import ../../example/3_advanced/http_server_cpp20/error.cpp] +[import ../../example/3_advanced/http_server_cpp20/repository.hpp] +[import ../../example/3_advanced/http_server_cpp20/repository.cpp] +[import ../../example/3_advanced/http_server_cpp20/handle_request.hpp] +[import ../../example/3_advanced/http_server_cpp20/handle_request.cpp] +[import ../../example/3_advanced/http_server_cpp20/server.hpp] +[import ../../example/3_advanced/http_server_cpp20/server.cpp] [import ../../example/3_advanced/connection_pool/main.cpp] [import ../../example/3_advanced/connection_pool/types.hpp] [import ../../example/3_advanced/connection_pool/repository.hpp] diff --git a/doc/qbk/21_examples.qbk b/doc/qbk/21_examples.qbk index c4246be0d..7d06790a4 100644 --- a/doc/qbk/21_examples.qbk +++ b/doc/qbk/21_examples.qbk @@ -331,7 +331,36 @@ This example assumes you have gone through the [link mysql.examples.setup setup] -[section:connection_pool A REST API server that uses connection pooling] +[section:http_server_cpp20 A REST API server that uses C++20 coroutines] + +This example assumes you have gone through the [link mysql.examples.setup setup]. + +[example_http_server_cpp20_main_cpp] + +[example_http_server_cpp20_types_hpp] + +[example_http_server_cpp20_error_hpp] + +[example_http_server_cpp20_error_cpp] + +[example_http_server_cpp20_repository_hpp] + +[example_http_server_cpp20_repository_cpp] + +[example_http_server_cpp20_handle_request_hpp] + +[example_http_server_cpp20_handle_request_cpp] + +[example_http_server_cpp20_server_hpp] + +[example_http_server_cpp20_server_cpp] + +[endsect] + + + + +[section:connection_pool A REST API server that uses asio::yield_context] This example assumes you have gone through the [link mysql.examples.setup setup]. diff --git a/example/3_advanced/http_server_cpp20/handle_request.cpp b/example/3_advanced/http_server_cpp20/handle_request.cpp index 7f5e67d0e..60b3af32e 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.cpp +++ b/example/3_advanced/http_server_cpp20/handle_request.cpp @@ -9,7 +9,7 @@ #include #if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED -//[example_connection_pool_handle_request_cpp +//[example_http_server_cpp20_handle_request_cpp // // File: handle_request.cpp // diff --git a/example/3_advanced/http_server_cpp20/handle_request.hpp b/example/3_advanced/http_server_cpp20/handle_request.hpp index 99a123473..5a8b80469 100644 --- a/example/3_advanced/http_server_cpp20/handle_request.hpp +++ b/example/3_advanced/http_server_cpp20/handle_request.hpp @@ -8,7 +8,7 @@ #ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP #define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_HANDLE_REQUEST_HPP -//[example_connection_pool_handle_request_hpp +//[example_http_server_cpp20_handle_request_hpp // // File: handle_request.hpp // diff --git a/example/3_advanced/http_server_cpp20/repository.cpp b/example/3_advanced/http_server_cpp20/repository.cpp index 4f23780b3..2b95ca2d2 100644 --- a/example/3_advanced/http_server_cpp20/repository.cpp +++ b/example/3_advanced/http_server_cpp20/repository.cpp @@ -9,7 +9,7 @@ #include #if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED -//[example_connection_pool_repository_cpp +//[example_http_server_cpp20_repository_cpp // // File: repository.cpp // diff --git a/example/3_advanced/http_server_cpp20/repository.hpp b/example/3_advanced/http_server_cpp20/repository.hpp index 1b5521562..df1e079a4 100644 --- a/example/3_advanced/http_server_cpp20/repository.hpp +++ b/example/3_advanced/http_server_cpp20/repository.hpp @@ -8,7 +8,7 @@ #ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP #define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_REPOSITORY_HPP -//[example_connection_pool_repository_hpp +//[example_http_server_cpp20_repository_hpp // // File: repository.hpp // diff --git a/example/3_advanced/http_server_cpp20/server.cpp b/example/3_advanced/http_server_cpp20/server.cpp index 6197559d4..4718e9119 100644 --- a/example/3_advanced/http_server_cpp20/server.cpp +++ b/example/3_advanced/http_server_cpp20/server.cpp @@ -9,7 +9,7 @@ #include #if defined(BOOST_ASIO_HAS_CO_AWAIT) && BOOST_PFR_CORE_NAME_ENABLED -//[example_connection_pool_server_cpp +//[example_http_server_cpp20_server_cpp // // File: server.cpp // diff --git a/example/3_advanced/http_server_cpp20/types.hpp b/example/3_advanced/http_server_cpp20/types.hpp index db609cadb..d16caf91b 100644 --- a/example/3_advanced/http_server_cpp20/types.hpp +++ b/example/3_advanced/http_server_cpp20/types.hpp @@ -8,7 +8,7 @@ #ifndef BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP #define BOOST_MYSQL_EXAMPLE_3_ADVANCED_HTTP_SERVER_CPP20_TYPES_HPP -//[example_http_cpp20_types_hpp +//[example_http_server_cpp20_types_hpp // // File: types.hpp // diff --git a/tools/scripts/examples_qbk.py b/tools/scripts/examples_qbk.py index c16d4e02b..6f66860b8 100644 --- a/tools/scripts/examples_qbk.py +++ b/tools/scripts/examples_qbk.py @@ -129,6 +129,19 @@ class MultiExample(NamedTuple): ] ADVANCED_EXAMPLES = [ + MultiExample('http_server_cpp20', [ + '3_advanced/http_server_cpp20/main.cpp', + '3_advanced/http_server_cpp20/types.hpp', + '3_advanced/http_server_cpp20/error.hpp', + '3_advanced/http_server_cpp20/error.cpp', + '3_advanced/http_server_cpp20/repository.hpp', + '3_advanced/http_server_cpp20/repository.cpp', + '3_advanced/http_server_cpp20/handle_request.hpp', + '3_advanced/http_server_cpp20/handle_request.cpp', + '3_advanced/http_server_cpp20/server.hpp', + '3_advanced/http_server_cpp20/server.cpp', + ], 'A REST API server that uses C++20 coroutines'), + MultiExample('connection_pool', [ '3_advanced/connection_pool/main.cpp', '3_advanced/connection_pool/types.hpp', @@ -139,7 +152,7 @@ class MultiExample(NamedTuple): '3_advanced/connection_pool/server.hpp', '3_advanced/connection_pool/server.cpp', '3_advanced/connection_pool/log_error.hpp', - ], 'A REST API server that uses connection pooling') + ], 'A REST API server that uses asio::yield_context'), ] ALL_EXAMPLES = TUTORIALS + SIMPLE_EXAMPLES + ADVANCED_EXAMPLES