diff --git a/src/mtconnect/configuration/agent_config.cpp b/src/mtconnect/configuration/agent_config.cpp index ab63fa01..1824dd36 100644 --- a/src/mtconnect/configuration/agent_config.cpp +++ b/src/mtconnect/configuration/agent_config.cpp @@ -1248,7 +1248,8 @@ namespace mtconnect::configuration { } catch (exception &e) { - LOG(info) << "Cannot load plugin " << name << " from " << path << " Reason: " << e.what(); + LOG(debug) << "Plugin " << name << " from " << path << " not found, Reason: " << e.what() + << ", trying next path if available."; } } diff --git a/src/mtconnect/configuration/agent_config.hpp b/src/mtconnect/configuration/agent_config.hpp index 742d1261..a5ba57a2 100644 --- a/src/mtconnect/configuration/agent_config.hpp +++ b/src/mtconnect/configuration/agent_config.hpp @@ -295,7 +295,7 @@ namespace mtconnect { else { LOG(debug) << "Cannot find file '" << file << "' " - << " in path " << path; + << " in path " << path << ", continuing..."; } } @@ -312,7 +312,7 @@ namespace mtconnect { if (!ec) paths.emplace_back(con); else - LOG(debug) << "Cannot file path: " << path << ", " << ec.message(); + LOG(debug) << "Cannot find path: " << path << ", " << ec.message() << ", skipping..."; } void addPathFront(std::list &paths, std::filesystem::path path) diff --git a/src/mtconnect/device_model/configuration/coordinate_systems.cpp b/src/mtconnect/device_model/configuration/coordinate_systems.cpp index dcd0007b..b3fbd47f 100644 --- a/src/mtconnect/device_model/configuration/coordinate_systems.cpp +++ b/src/mtconnect/device_model/configuration/coordinate_systems.cpp @@ -33,7 +33,8 @@ namespace mtconnect { Requirement("Rotation", ValueType::VECTOR, 3, false), Requirement("TranslationDataSet", ValueType::DATA_SET, false), Requirement("RotationDataSet", ValueType::DATA_SET, false)}); - transformation->setOrder({"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); + transformation->setOrder( + {"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); auto coordinateSystem = make_shared(Requirements { Requirement("id", true), Requirement("name", false), Requirement("nativeName", false), diff --git a/src/mtconnect/device_model/configuration/motion.cpp b/src/mtconnect/device_model/configuration/motion.cpp index bac6f33f..7a3d60b2 100644 --- a/src/mtconnect/device_model/configuration/motion.cpp +++ b/src/mtconnect/device_model/configuration/motion.cpp @@ -30,7 +30,8 @@ namespace mtconnect { Requirement("Rotation", ValueType::VECTOR, 3, false), Requirement("TranslationDataSet", ValueType::DATA_SET, false), Requirement("RotationDataSet", ValueType::DATA_SET, false)}); - transformation->setOrder({"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); + transformation->setOrder( + {"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); static auto motion = make_shared(Requirements { Requirement("id", true), Requirement("parentIdRef", false), diff --git a/src/mtconnect/device_model/configuration/solid_model.cpp b/src/mtconnect/device_model/configuration/solid_model.cpp index 76a5fd66..8d3e4e36 100644 --- a/src/mtconnect/device_model/configuration/solid_model.cpp +++ b/src/mtconnect/device_model/configuration/solid_model.cpp @@ -29,11 +29,12 @@ namespace mtconnect { if (!solidModel) { static auto transformation = make_shared( - Requirements {Requirement("Translation", ValueType::VECTOR, 3, false), - Requirement("Rotation", ValueType::VECTOR, 3, false), - Requirement("TranslationDataSet", ValueType::DATA_SET, false), - Requirement("RotationDataSet", ValueType::DATA_SET, false)}); - transformation->setOrder({"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); + Requirements {Requirement("Translation", ValueType::VECTOR, 3, false), + Requirement("Rotation", ValueType::VECTOR, 3, false), + Requirement("TranslationDataSet", ValueType::DATA_SET, false), + Requirement("RotationDataSet", ValueType::DATA_SET, false)}); + transformation->setOrder( + {"Translation", "TranslationDataSet", "Rotation", "RotationDataSet"}); solidModel = make_shared( Requirements {{"id", true}, diff --git a/src/mtconnect/entity/xml_parser.cpp b/src/mtconnect/entity/xml_parser.cpp index 53cf80e4..a07877fd 100644 --- a/src/mtconnect/entity/xml_parser.cpp +++ b/src/mtconnect/entity/xml_parser.cpp @@ -287,7 +287,7 @@ namespace mtconnect::entity { } else { - LOG(warning) << "Unexpected element: " << nodeQName(child); + // LOG(warning) << "Unexpected element: " << nodeQName(child); errors.emplace_back( new EntityError("Invalid element '" + nodeQName(child) + "'", qname)); } diff --git a/src/mtconnect/parser/xml_parser.cpp b/src/mtconnect/parser/xml_parser.cpp index 9734d0ad..20185ceb 100644 --- a/src/mtconnect/parser/xml_parser.cpp +++ b/src/mtconnect/parser/xml_parser.cpp @@ -202,13 +202,27 @@ namespace mtconnect::parser { { auto device = entity::XmlParser::parseXmlNode(Device::getRoot(), nodeset->nodeTab[i], errors); + if (device) + { deviceList.emplace_back(dynamic_pointer_cast(device)); + } + else + { + LOG(error) << "Failed to parse device, skipping"; + } if (!errors.empty()) { for (auto &e : errors) - LOG(warning) << "Error parsing device: " << e->what(); + { + if (device) + LOG(warning) << "When loading device " << device->get("name") + << ", A problem was skipped: " + << e->what(); + else + LOG(error) << "Failed to load device: " << e->what(); + } } } } diff --git a/src/mtconnect/pipeline/shdr_token_mapper.cpp b/src/mtconnect/pipeline/shdr_token_mapper.cpp index efef18c9..3baf14f3 100644 --- a/src/mtconnect/pipeline/shdr_token_mapper.cpp +++ b/src/mtconnect/pipeline/shdr_token_mapper.cpp @@ -166,8 +166,8 @@ namespace mtconnect { } catch (entity::PropertyError &e) { - LOG(warning) << "Cannot convert value for data item id '" << dataItem->getId() - << "': " << *token << " - " << e.what(); + LOG(debug) << "Cannot convert value for data item id '" << dataItem->getId() + << "': " << *token << " - " << e.what(); if (schemaVersion >= SCHEMA_VERSION(2, 5) && validation) { props.insert_or_assign("quality", "INVALID"s); diff --git a/src/mtconnect/sink/rest_sink/parameter.hpp b/src/mtconnect/sink/rest_sink/parameter.hpp index 541e4dcd..e7348d64 100644 --- a/src/mtconnect/sink/rest_sink/parameter.hpp +++ b/src/mtconnect/sink/rest_sink/parameter.hpp @@ -64,6 +64,9 @@ namespace mtconnect::sink::rest_sink { Parameter(const std::string &n, ParameterType t = STRING, UrlPart p = PATH) : m_name(n), m_type(t), m_part(p) {} + Parameter(const std::string_view &n, ParameterType t = STRING, UrlPart p = PATH) + : m_name(n), m_type(t), m_part(p) + {} Parameter(const Parameter &o) = default; /// @brief to support std::set interface diff --git a/src/mtconnect/sink/rest_sink/response.hpp b/src/mtconnect/sink/rest_sink/response.hpp index a380c609..80bf2356 100644 --- a/src/mtconnect/sink/rest_sink/response.hpp +++ b/src/mtconnect/sink/rest_sink/response.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include "cached_file.hpp" @@ -64,6 +65,9 @@ namespace mtconnect { std::optional m_requestId; ///< Request id from websocket sub CachedFilePtr m_file; ///< Cached file if a file is being returned + + /// @brief Additional per-response header fields (e.g. for CORS preflight) + std::list> m_fields; }; using ResponsePtr = std::unique_ptr; diff --git a/src/mtconnect/sink/rest_sink/routing.hpp b/src/mtconnect/sink/rest_sink/routing.hpp index f3ee2f37..709d13e4 100644 --- a/src/mtconnect/sink/rest_sink/routing.hpp +++ b/src/mtconnect/sink/rest_sink/routing.hpp @@ -17,8 +17,10 @@ #pragma once +#include #include +#include #include #include #include @@ -84,7 +86,8 @@ namespace mtconnect::sink::rest_sink { m_pattern(pattern), m_command(request), m_function(function), - m_swagger(swagger) + m_swagger(swagger), + m_catchAll(true) {} /// @brief Added summary and description to the routing @@ -295,6 +298,15 @@ namespace mtconnect::sink::rest_sink { return true; } + /// @brief check if the routing's path pattern matches a given path (ignoring verb) + /// @param[in] path the request path to test + /// @return `true` if the path matches this routing's pattern + bool matchesPath(const std::string &path) const + { + std::smatch m; + return std::regex_match(path, m, m_pattern); + } + /// @brief check if this is related to a swagger API /// @returns `true` if related to swagger auto isSwagger() const { return m_swagger; } @@ -304,6 +316,10 @@ namespace mtconnect::sink::rest_sink { /// @brief Get the routing `verb` const auto &getVerb() const { return m_verb; } + /// @brief Check if the route is a catch-all (every path segment is a parameter) + /// @returns `true` if all path segments are parameters (e.g. `/{device}`) + auto isCatchAll() const { return m_catchAll; } + /// @brief Get the optional command associated with the routing /// @returns optional routing const auto &getCommand() const { return m_command; } @@ -319,21 +335,60 @@ namespace mtconnect::sink::rest_sink { protected: void pathParameters(std::string s) { - std::regex reg("\\{([^}]+)\\}"); - std::smatch match; std::stringstream pat; - while (regex_search(s, match, reg)) + using namespace boost::algorithm; + using SplitList = std::list>; + + SplitList parts; + auto pos = s.find_first_not_of('/'); + if (pos != std::string::npos) { - pat << match.prefix() << "([^/]+)"; - m_pathParameters.emplace_back(match[1]); - s = match.suffix().str(); + auto range = boost::make_iterator_range(s.begin() + pos, s.end()); + split(parts, range, [](char c) { return c == '/'; }); + } + + bool hasLiteral = false; + for (auto &p : parts) + { + auto start = p.begin(); + auto end = p.end(); + + auto openBrace = std::find(start, end, '{'); + decltype(openBrace) closeBrace {end}; + if (openBrace != end && std::distance(openBrace, end) > 2) + closeBrace = std::find(openBrace + 1, end, '}'); + + pat << "/"; + if (openBrace != end && closeBrace != end) + { + if (openBrace > start) + { + pat << std::string_view(start, openBrace); + hasLiteral = true; + } + std::string_view param(openBrace + 1, closeBrace); + pat << "([^/]+)"; + if (closeBrace + 1 < end) + { + pat << std::string_view(closeBrace + 1, end); + hasLiteral = true; + } + m_pathParameters.emplace_back(param); + } + else + { + pat << std::string_view(start, end); + hasLiteral = true; + } } - pat << s; pat << "/?"; m_patternText = pat.str(); m_pattern = std::regex(m_patternText); + + // A route is catch-all if it has parameters but no literal path segments + m_catchAll = !m_pathParameters.empty() && !hasLiteral; } void queryParameters(std::string s) @@ -513,5 +568,6 @@ namespace mtconnect::sink::rest_sink { std::optional m_description; bool m_swagger = false; + bool m_catchAll = false; }; } // namespace mtconnect::sink::rest_sink diff --git a/src/mtconnect/sink/rest_sink/server.cpp b/src/mtconnect/sink/rest_sink/server.cpp index b0580486..4cb1b8d2 100644 --- a/src/mtconnect/sink/rest_sink/server.cpp +++ b/src/mtconnect/sink/rest_sink/server.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include @@ -413,4 +415,44 @@ namespace mtconnect::sink::rest_sink { // addRouting({boost::beast::http::verb::get, "/swagger.yaml", handler, true}); } + bool Server::handleOptionsRequest(SessionPtr session, const RequestPtr request) + { + using namespace boost; + using namespace adaptors; + set specificVerbs; + set catchAllVerbs; + for (const auto &r : m_routings) + { + if (!r.isSwagger() && r.matchesPath(request->m_path)) + { + if (r.isCatchAll()) + catchAllVerbs.insert(r.getVerb()); + else + specificVerbs.insert(r.getVerb()); + } + } + + // If any specific route matched, use only those; otherwise fall back to catch-alls + auto &verbs = specificVerbs.empty() ? catchAllVerbs : specificVerbs; + + // OPTIONS is always allowed + verbs.insert(http::verb::options); + + // Build the Allow / Access-Control-Allow-Methods header value + string methods = algorithm::join( + verbs | transformed([](http::verb v) { return string(http::to_string(v)); }), ", "); + + auto response = std::make_unique(status::no_content, "", "text/plain"); + response->m_close = false; + response->m_fields.emplace_back("Allow", methods); + response->m_fields.emplace_back("Access-Control-Allow-Methods", methods); + response->m_fields.emplace_back("Access-Control-Allow-Headers", + "Content-Type, Accept, Accept-Encoding"); + response->m_fields.emplace_back("Access-Control-Max-Age", "86400"); + + session->writeResponse(std::move(response)); + + return true; + } + } // namespace mtconnect::sink::rest_sink diff --git a/src/mtconnect/sink/rest_sink/server.hpp b/src/mtconnect/sink/rest_sink/server.hpp index 2fdd8010..73377b42 100644 --- a/src/mtconnect/sink/rest_sink/server.hpp +++ b/src/mtconnect/sink/rest_sink/server.hpp @@ -173,6 +173,10 @@ namespace mtconnect::sink::rest_sink { else message = "Command failed: " + *request->m_command; } + else if (request->m_verb == boost::beast::http::verb::options) + { + success = handleOptionsRequest(session, request); + } else { for (auto &r : m_routings) @@ -298,6 +302,16 @@ namespace mtconnect::sink::rest_sink { const void renderSwaggerResponse(T &format); /// @} + /// @name CORS Support + /// @{ + /// + /// @brief Handle OPTIONS request for CORS preflight requests + /// @param[in] session the client session + /// @param[in] request the incoming request + /// @return `true` if the request was handled, otherwise `false` and a 404 will be returned + bool handleOptionsRequest(SessionPtr session, const RequestPtr request); + /// @} + protected: boost::asio::io_context &m_context; diff --git a/src/mtconnect/sink/rest_sink/session_impl.cpp b/src/mtconnect/sink/rest_sink/session_impl.cpp index e241d5cc..f188dd4d 100644 --- a/src/mtconnect/sink/rest_sink/session_impl.cpp +++ b/src/mtconnect/sink/rest_sink/session_impl.cpp @@ -199,8 +199,9 @@ namespace mtconnect::sink::rest_sink { auto &msg = m_parser->get(); const auto &remote = m_remote; - // Check for put, post, or delete - if (msg.method() != http::verb::get) + // Check for put, post, or delete (allow OPTIONS for CORS preflight) + if (msg.method() == http::verb::put || msg.method() == http::verb::post || + msg.method() == http::verb::delete_) { if (!m_allowPuts) { @@ -376,6 +377,10 @@ namespace mtconnect::sink::rest_sink { { res->set(http::field::location, *response.m_location); } + for (const auto &f : response.m_fields) + { + res->set(f.first, f.second); + } } template diff --git a/src/mtconnect/source/adapter/shdr/connector.cpp b/src/mtconnect/source/adapter/shdr/connector.cpp index 551b79a5..be774fe6 100644 --- a/src/mtconnect/source/adapter/shdr/connector.cpp +++ b/src/mtconnect/source/adapter/shdr/connector.cpp @@ -98,10 +98,10 @@ namespace mtconnect::source::adapter::shdr { if (ec) { - LOG(error) << "Cannot resolve address: " << m_server << ":" << m_port; - LOG(error) << ec.category().message(ec.value()) << ": " << ec.message(); - LOG(error) << "Will retry resolution of " << m_server << " in " << m_reconnectInterval.count() - << " milliseconds"; + LOG(warning) << "Cannot resolve address: " << m_server << ":" << m_port; + LOG(warning) << ec.message(); + LOG(warning) << "Will retry resolution of " << m_server << " in " + << m_reconnectInterval.count() << " milliseconds"; m_timer.expires_after(m_reconnectInterval); m_timer.async_wait([this](boost::system::error_code ec) { @@ -136,8 +136,9 @@ namespace mtconnect::source::adapter::shdr { return true; } - /// @brief Attempt to reconnect after a delay. If the server is a hostname, re-resolve it to get the current IP - /// address in case it has changed. If the server is a static IP address, just reconnect. + /// @brief Attempt to reconnect after a delay. If the server is a hostname, re-resolve it to get + /// the current IP address in case it has changed. If the server is a static IP address, just + /// reconnect. inline void Connector::asyncTryConnect() { NAMED_SCOPE("Connector::asyncTryConnect"); @@ -203,7 +204,7 @@ namespace mtconnect::source::adapter::shdr { auto remote = m_socket.remote_endpoint(rec); if (rec) { - LOG(error) << "Failed to get remote endpoint: " << rec.message(); + LOG(warning) << "Failed to get remote endpoint: " << rec.message(); } else { @@ -233,7 +234,7 @@ namespace mtconnect::source::adapter::shdr { if (ec) { - LOG(error) << ec.category().message(ec.value()) << ": " << ec.message(); + LOG(error) << ec.message(); reconnect(); } else @@ -271,7 +272,7 @@ namespace mtconnect::source::adapter::shdr { if (ec) { - LOG(error) << ec.category().message(ec.value()) << ": " << ec.message(); + LOG(error) << ec.message(); reconnect(); } } @@ -292,15 +293,14 @@ namespace mtconnect::source::adapter::shdr { m_receiveTimeout.async_wait([this](sys::error_code ec) { if (!ec) { - LOG(error) << "(Port:" << m_localPort << ")" - << " connect: Did not receive data for over: " << m_receiveTimeLimit.count() - << " ms"; + LOG(warning) << "(Port:" << m_localPort << ")" + << " connect: Did not receive data for over: " << m_receiveTimeLimit.count() + << " ms"; asio::dispatch(m_strand, boost::bind(&Connector::reconnect, this)); } else if (ec != boost::asio::error::operation_aborted) { - LOG(error) << "Receive timeout: " << ec.category().message(ec.value()) << ": " - << ec.message(); + LOG(error) << "Receive timeout: " << ec.message(); } }); } @@ -413,7 +413,7 @@ namespace mtconnect::source::adapter::shdr { } else if (ec != boost::asio::error::operation_aborted) { - LOG(error) << "heartbeat: " << ec.category().message(ec.value()) << ": " << ec.message(); + LOG(error) << "heartbeat: " << ec.message(); } } diff --git a/test_package/http_server_test.cpp b/test_package/http_server_test.cpp index 81c0a1c5..88b0c580 100644 --- a/test_package/http_server_test.cpp +++ b/test_package/http_server_test.cpp @@ -683,6 +683,301 @@ TEST_F(HttpServerTest, additional_header_fields) ASSERT_EQ("https://foo.example", f2->second); } +TEST_F(HttpServerTest, options_returns_allowed_methods_for_get_only_path) +{ + auto handler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/probe", handler}); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/probe"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_EQ(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); + + auto acam = m_client->m_fields.find("Access-Control-Allow-Methods"); + ASSERT_NE(m_client->m_fields.end(), acam); + EXPECT_EQ(allow->second, acam->second); + + auto acah = m_client->m_fields.find("Access-Control-Allow-Headers"); + ASSERT_NE(m_client->m_fields.end(), acah); + + auto acma = m_client->m_fields.find("Access-Control-Max-Age"); + ASSERT_NE(m_client->m_fields.end(), acma); + EXPECT_EQ("86400", acma->second); +} + +TEST_F(HttpServerTest, options_returns_get_put_and_delete_when_registered) +{ + auto getHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + auto putHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Put ok"); + session->writeResponse(std::move(resp)); + return true; + }; + auto deleteHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Deleted"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/asset/{id}", getHandler}); + m_server->addRouting({http::verb::put, "/asset/{id}", putHandler}); + m_server->addRouting({http::verb::delete_, "/asset/{id}", deleteHandler}); + m_server->allowPuts(); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/asset/123"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_NE(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_NE(string::npos, allow->second.find("DELETE")); +} + +TEST_F(HttpServerTest, options_returns_get_when_a_specific_and_wildcard_route_are_given) +{ + auto getHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + auto putHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Put ok"); + session->writeResponse(std::move(resp)); + return true; + }; + auto deleteHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Deleted"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/current", getHandler}); + m_server->addRouting({http::verb::put, "/{device}?timestamp={timestamp}", putHandler}); + m_server->addRouting({http::verb::delete_, "/{device}?timestamp={timestamp}", deleteHandler}); + m_server->allowPuts(); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/current"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_EQ(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); +} + +TEST_F(HttpServerTest, options_returns_get_when_complex_path_route_are_given) +{ + auto getHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + auto putHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Put ok"); + session->writeResponse(std::move(resp)); + return true; + }; + auto deleteHandler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Deleted"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/{device}/current", getHandler}); + m_server->addRouting({http::verb::put, "/{device}/{command}?timestamp={timestamp}", putHandler}); + m_server->addRouting( + {http::verb::delete_, "/{device}/{command}?timestamp={timestamp}", deleteHandler}); + m_server->allowPuts(); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/mydevice/current"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_EQ(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); +} + +TEST_F(HttpServerTest, options_allowed_even_when_puts_disabled) +{ + auto handler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/probe", handler}); + // Note: puts are NOT enabled + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/probe"); + ASSERT_TRUE(m_client->m_done); + + // OPTIONS should succeed even though puts are disabled + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_EQ(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); +} + +TEST_F(HttpServerTest, options_includes_configured_cors_origin_header) +{ + m_server->setHttpHeaders({"Access-Control-Allow-Origin:*"}); + + auto handler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({http::verb::get, "/probe", handler}); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/probe"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + // Access-Control-Allow-Origin comes from the configured HttpHeaders + auto acao = m_client->m_fields.find("Access-Control-Allow-Origin"); + ASSERT_NE(m_client->m_fields.end(), acao); + ASSERT_EQ("*", acao->second); + + // Access-Control-Allow-Methods comes from the OPTIONS handler + auto acam = m_client->m_fields.find("Access-Control-Allow-Methods"); + ASSERT_NE(m_client->m_fields.end(), acam); + EXPECT_NE(string::npos, acam->second.find("GET")); + EXPECT_NE(string::npos, acam->second.find("OPTIONS")); + EXPECT_EQ(string::npos, acam->second.find("PUT")); + EXPECT_EQ(string::npos, acam->second.find("POST")); + EXPECT_EQ(string::npos, acam->second.find("DELETE")); +} + +TEST_F(HttpServerTest, options_returns_correctly_for_path_with_parameter_value) +{ + auto handler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({boost::beast::http::verb::get, "/cancel/id={string}", handler}) + .document("MTConnect WebServices Cancel Stream", "Cancels a streaming sample request") + .command("cancel"); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/cancel/id=12345"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_EQ(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); + + auto acam = m_client->m_fields.find("Access-Control-Allow-Methods"); + ASSERT_NE(m_client->m_fields.end(), acam); + EXPECT_EQ(allow->second, acam->second); + + auto acah = m_client->m_fields.find("Access-Control-Allow-Headers"); + ASSERT_NE(m_client->m_fields.end(), acah); + + auto acma = m_client->m_fields.find("Access-Control-Max-Age"); + ASSERT_NE(m_client->m_fields.end(), acma); + EXPECT_EQ("86400", acma->second); +} + +TEST_F(HttpServerTest, should_handle_routings_with_just_a_regex) +{ + auto handler = [&](SessionPtr session, RequestPtr request) -> bool { + ResponsePtr resp = make_unique(status::ok, "Done"); + session->writeResponse(std::move(resp)); + return true; + }; + + m_server->addRouting({boost::beast::http::verb::get, regex("/.+"), handler}); + m_server->addRouting({http::verb::put, "/{device}?timestamp={timestamp}", handler}); + + start(); + startClient(); + + m_client->spawnRequest(http::verb::options, "/file.xsd"); + ASSERT_TRUE(m_client->m_done); + EXPECT_EQ(int(http::status::no_content), m_client->m_status); + + auto allow = m_client->m_fields.find("Allow"); + ASSERT_NE(m_client->m_fields.end(), allow); + EXPECT_NE(string::npos, allow->second.find("GET")); + EXPECT_NE(string::npos, allow->second.find("OPTIONS")); + EXPECT_NE(string::npos, allow->second.find("PUT")); + EXPECT_EQ(string::npos, allow->second.find("POST")); + EXPECT_EQ(string::npos, allow->second.find("DELETE")); + + auto acam = m_client->m_fields.find("Access-Control-Allow-Methods"); + ASSERT_NE(m_client->m_fields.end(), acam); + EXPECT_EQ(allow->second, acam->second); + + auto acah = m_client->m_fields.find("Access-Control-Allow-Headers"); + ASSERT_NE(m_client->m_fields.end(), acah); + + auto acma = m_client->m_fields.find("Access-Control-Max-Age"); + ASSERT_NE(m_client->m_fields.end(), acma); + EXPECT_EQ("86400", acma->second); +} + const string CertFile(TEST_RESOURCE_DIR "/user.crt"); const string KeyFile {TEST_RESOURCE_DIR "/user.key"}; const string DhFile {TEST_RESOURCE_DIR "/dh2048.pem"}; diff --git a/test_package/routing_test.cpp b/test_package/routing_test.cpp index 3235cea3..489458af 100644 --- a/test_package/routing_test.cpp +++ b/test_package/routing_test.cpp @@ -304,3 +304,59 @@ TEST_F(RoutingTest, simple_put_with_trailing_slash) ASSERT_TRUE(r.matches(0, request)); ASSERT_EQ("ADevice", get(request->m_parameters["device"])); } + +TEST_F(RoutingTest, matchesPath_matches_simple_path) +{ + Routing r(verb::get, "/probe", m_func); + + EXPECT_TRUE(r.matchesPath("/probe")); + EXPECT_TRUE(r.matchesPath("/probe/")); + EXPECT_FALSE(r.matchesPath("/sample")); + EXPECT_FALSE(r.matchesPath("/probe/extra")); +} + +TEST_F(RoutingTest, matchesPath_matches_path_with_parameter) +{ + Routing r(verb::get, "/{device}/probe", m_func); + + EXPECT_TRUE(r.matchesPath("/ABC123/probe")); + EXPECT_TRUE(r.matchesPath("/mydevice/probe")); + EXPECT_FALSE(r.matchesPath("/probe")); + EXPECT_FALSE(r.matchesPath("/dev/probe/extra")); +} + +TEST_F(RoutingTest, matchesPath_ignores_verb) +{ + Routing getRoute(verb::get, "/asset/{id}", m_func); + Routing putRoute(verb::put, "/asset/{id}", m_func); + Routing deleteRoute(verb::delete_, "/asset/{id}", m_func); + + // matchesPath should match regardless of the routing's verb + EXPECT_TRUE(getRoute.matchesPath("/asset/A1")); + EXPECT_TRUE(putRoute.matchesPath("/asset/A1")); + EXPECT_TRUE(deleteRoute.matchesPath("/asset/A1")); + + // Different paths should not match + EXPECT_FALSE(getRoute.matchesPath("/probe")); + EXPECT_FALSE(putRoute.matchesPath("/probe")); + EXPECT_FALSE(deleteRoute.matchesPath("/probe")); +} + +TEST_F(RoutingTest, matchesPath_works_with_regex_routing) +{ + Routing r(verb::get, regex("/.+"), m_func); + + EXPECT_TRUE(r.matchesPath("/anything")); + EXPECT_TRUE(r.matchesPath("/some/deep/path")); + EXPECT_FALSE(r.matchesPath("/")); +} + +TEST_F(RoutingTest, matchesPath_with_query_parameters_in_pattern) +{ + Routing r(verb::get, "/{device}/sample?from={unsigned_integer}&count={integer:100}", m_func); + + // matchesPath only checks the path component, query params in the pattern don't affect it + EXPECT_TRUE(r.matchesPath("/ABC123/sample")); + EXPECT_TRUE(r.matchesPath("/device1/sample/")); + EXPECT_FALSE(r.matchesPath("/sample")); +}