LCOV - code coverage report
Current view: top level - http/server/routing - route.cpp (source / functions) Coverage Total Hit
Test: coverage_filtered.info Lines: 98.0 % 200 196
Test Date: 2026-11-10 10:38:07 Functions: 100.0 % 32 32

            Line data    Source code
       1              : #include "route.hpp"
       2              : #include "route_pattern.hpp"
       3              : #include "../response.hpp"
       4              : #include "../virtual_host.hpp"
       5              : #include <regex>
       6              : #include <algorithm>
       7              : #include <mutex>
       8              : #include "../../../util/ascii.hpp"
       9              : #include "../../../util/logger.hpp"
      10              : 
      11              : namespace thinger::http {
      12              : 
      13              : // Regex of the whole pattern, compiled by the first matches()
      14              : struct route::lazy_regex {
      15              :     std::once_flag compiled;
      16              :     std::regex regex;
      17              : };
      18              : 
      19        34484 : route::route(const std::string& pattern)
      20        34484 :     : pattern_(pattern)
      21        34484 :     , regex_(std::make_shared<lazy_regex>())
      22              : {
      23              :     // Parameters in order of appearance (the first one of a repeated name), and their
      24              :     // custom regex if they have one
      25        34484 :     auto tokens = detail::parse_route_pattern(pattern);
      26        34475 :     std::string parameter_regexes;
      27        75305 :     for (const auto& token : tokens) {
      28        40830 :         if (!token.parameter) continue;
      29         5417 :         parameter_regexes += '(';
      30         5417 :         parameter_regexes += token.regex;
      31         5417 :         parameter_regexes += ')';
      32         5417 :         if (!token.regex.empty()) {
      33         2460 :             parameter_patterns_[token.text] = token.regex;
      34              :         }
      35         5417 :         if (std::find(parameters_.begin(), parameters_.end(), token.text) == parameters_.end()) {
      36         5417 :             parameters_.push_back(token.text);
      37              :         }
      38              :     }
      39              : 
      40              :     // An invalid parameter regex throws std::regex_error here. They are compiled together,
      41              :     // a group per parameter, as a back-reference may refer to the group of another one;
      42              :     // the literal text, escaped in the regex of matches(), cannot make it invalid
      43        34475 :     if (!parameter_patterns_.empty()) {
      44         2277 :         [[maybe_unused]] const std::regex validated(parameter_regexes);
      45         2265 :     }
      46        34865 : }
      47              : 
      48           37 : route& route::deferred_body(bool enabled) {
      49           37 :     deferred_body_ = enabled;
      50           37 :     return *this;
      51              : }
      52              : 
      53          108 : route& route::accepts(std::vector<std::string> types) {
      54          579 :     for (auto& type : types) ::thinger::util::ascii::to_lower(type);
      55          108 :     accepted_types_ = std::move(types);
      56          108 :     return *this;
      57              : }
      58              : 
      59         1110 : route& route::max_body_size(std::uint64_t size) {
      60         1110 :     max_body_size_ = size;
      61         1110 :     return *this;
      62              : }
      63              : 
      64          801 : route& route::timeout(std::chrono::milliseconds timeout, http_response::status status) {
      65          801 :     timeout_ = std::max(timeout, std::chrono::milliseconds{0});
      66          801 :     timeout_status_ = status;
      67          801 :     return *this;
      68              : }
      69              : 
      70           90 : route& route::request_timeout(std::chrono::milliseconds timeout) {
      71           90 :     request_timeout_ = std::max(timeout, std::chrono::milliseconds{0});
      72           90 :     return *this;
      73              : }
      74              : 
      75          198 : route& route::cancel_on(std::initializer_list<cancel_reason> reasons) {
      76          198 :     cancel_on_ = 0;
      77          396 :     for (auto reason : reasons) {
      78          198 :         if (reason != cancel_reason::none) cancel_on_ |= static_cast<uint8_t>(1u << static_cast<unsigned>(reason));
      79              :     }
      80          198 :     return *this;
      81              : }
      82              : 
      83            9 : route& route::decode_limits(body_limits limits) {
      84            9 :     decode_limits_ = limits;
      85            9 :     return *this;
      86              : }
      87              : 
      88           49 : route& route::summary(const std::string& text) {
      89           49 :     summary_ = text;
      90           49 :     return *this;
      91              : }
      92              : 
      93           18 : route& route::description(const std::string& desc) {
      94           18 :     description_ = desc;
      95           18 :     return *this;
      96              : }
      97              : 
      98           76 : route& route::tag(const std::string& name) {
      99           76 :     if (std::find(tags_.begin(), tags_.end(), name) == tags_.end()) {
     100           73 :         tags_.push_back(name);
     101              :     }
     102           76 :     return *this;
     103              : }
     104              : 
     105          355 : route& route::tags(const std::vector<std::string>& names) {
     106          428 :     for (const auto& name : names) tag(name);
     107          355 :     return *this;
     108              : }
     109              : 
     110           15 : route& route::operation_id(const std::string& id) {
     111           15 :     operation_id_ = id;
     112           15 :     return *this;
     113              : }
     114              : 
     115           15 : route& route::deprecated(bool value) {
     116           15 :     deprecated_ = value;
     117           15 :     return *this;
     118              : }
     119              : 
     120           18 : route& route::path_param(const std::string& name, const std::string& description, nlohmann::json schema) {
     121           18 :     if (std::find(parameters_.begin(), parameters_.end(), name) == parameters_.end()) {
     122            0 :         LOG_WARNING("Route {} documents unknown path parameter '{}'", pattern_, name);
     123              :     }
     124           18 :     param_docs_.push_back({name, "path", description, std::move(schema), true});
     125           18 :     return *this;
     126           54 : }
     127              : 
     128           15 : route& route::query_param(const std::string& name, const std::string& description,
     129              :                           nlohmann::json schema, bool required) {
     130           15 :     param_docs_.push_back({name, "query", description, std::move(schema), required});
     131           15 :     return *this;
     132           45 : }
     133              : 
     134           30 : route& route::returns(int status, const std::string& description, nlohmann::json schema, nlohmann::json example) {
     135           30 :     responses_[status] = {description, std::move(schema), std::move(example)};
     136           30 :     return *this;
     137           30 : }
     138              : 
     139           27 : route& route::example(nlohmann::json body) {
     140           27 :     examples_.push_back(std::move(body));
     141           27 :     return *this;
     142              : }
     143              : 
     144          454 : route& route::hidden(bool value) {
     145          454 :     hidden_ = value;
     146          454 :     return *this;
     147              : }
     148              : 
     149           63 : std::string route::get_parameter_pattern(const std::string& name) const {
     150           63 :     auto it = parameter_patterns_.find(name);
     151           63 :     return it != parameter_patterns_.end() ? it->second : std::string{};
     152              : }
     153              : 
     154          145 : route& route::meta(const std::string& key, nlohmann::json value) {
     155          145 :     metadata_[key] = std::move(value);
     156          145 :     return *this;
     157              : }
     158              : 
     159           64 : bool route::has_meta(const std::string& key) const {
     160           64 :     return metadata_.contains(key);
     161              : }
     162              : 
     163           62 : const nlohmann::json& route::get_meta(const std::string& key) const {
     164           62 :     static const nlohmann::json null_value;
     165           62 :     auto it = metadata_.find(key);
     166           62 :     return it != metadata_.end() ? *it : null_value;
     167              : }
     168              : 
     169       780336 : bool route::matches(const std::string& path, std::smatch& matches) const {
     170       780336 :     std::call_once(regex_->compiled, [this] {
     171          231 :         regex_->regex = std::regex(detail::pattern_regex(detail::parse_route_pattern(pattern_)));
     172          231 :     });
     173       780336 :     return std::regex_match(path, matches, regex_->regex);
     174              : }
     175              : 
     176         1031 : bool route::match_body_type(request& req, response& res) const {
     177              :     // Matched once per request: before its body is read, then to decode it
     178         1031 :     if (req.body_media_type_) return true;
     179              : 
     180          581 :     const auto& settings = req.get_body_settings();
     181          581 :     const auto& http_request = *req.get_http_request();
     182          581 :     const auto& accepted = accepted_types_.empty() ? settings.accepted : accepted_types_;
     183              : 
     184              :     // Content-Type is a single value: repeated with different values, the type is ambiguous
     185          581 :     const std::string* content_type = nullptr;
     186         3029 :     for (const auto& [key, value] : http_request.get_headers()) {
     187         2451 :         if (!headers::is_header(key, header::content_type)) continue;
     188          569 :         if (!content_type) {
     189          563 :             content_type = &value;
     190            6 :         } else if (value != *content_type) {
     191            9 :             res.error(http_response::status::unsupported_media_type, "Multiple Content-Type values");
     192            3 :             return false;
     193              :         }
     194              :     }
     195              : 
     196              :     // A media type as a decoder lists it (the usual "application/json", also assumed without
     197              :     // Content-Type) is accepted as http::match_body_type() would, without parsing it again
     198          578 :     std::string_view value = content_type ? std::string_view(*content_type) : std::string_view{};
     199          578 :     auto [type, format] = settings.decoders.find_plain_media_type(value.empty() ? default_body_media_type : value);
     200          578 :     if (type && std::any_of(accepted.begin(), accepted.end(), [&](const std::string& entry) {
     201          773 :             return entry == type->type || entry == format->name;
     202              :         })) {
     203          458 :         req.body_media_type_ = type;
     204          458 :         req.body_format_ = format;
     205          458 :         return true;
     206              :     }
     207              : 
     208          120 :     auto& match = req.body_type_.emplace(http::match_body_type(settings.decoders, accepted, value));
     209          120 :     if (match.accepted && !match.format && decodes_body_) {
     210              :         // Listed by accepts(), but no decoder can give it to the callback
     211            0 :         LOG_WARNING("Route {} accepts {}, but there is no body decoder for it", pattern_, match.type.type);
     212            0 :         match.accepted = false;
     213            0 :         match.error = "Unsupported Media Type: " + match.type.type;
     214              :     }
     215          120 :     if (!match.accepted) {
     216           84 :         res.error(http_response::status::unsupported_media_type, match.error);
     217           84 :         req.body_type_.reset();
     218           84 :         return false;
     219              :     }
     220           36 :     req.body_media_type_ = &match.type;
     221           36 :     req.body_format_ = match.format;
     222           36 :     return true;
     223              : }
     224              : 
     225         2661 : bool route::accepts_body(request& req, response& res) const {
     226              :     // Routes reading the body themselves accept any type unless they list theirs (or
     227              :     // validate it against a schema: then it must be decoded)
     228         2661 :     if (!decodes_body_ && accepted_types_.empty()) return true;
     229          572 :     if (!req.has_pending_body() && req.get_http_request()->get_body().empty()) return true;
     230          572 :     return match_body_type(req, res);
     231              : }
     232              : 
     233          476 : bool route::decode_body(request& req, response& res, nlohmann::json& json) const {
     234              :     // An empty body is null, whatever its type (the schema decides whether it is valid)
     235          476 :     const auto& body = req.get_http_request()->get_body();
     236          476 :     if (!body.empty()) {
     237          459 :         if (!match_body_type(req, res)) return false;
     238              :         try {
     239              :             // Limits of the route, else of its virtual host, else of the application
     240          459 :             const auto* host = req.get_virtual_host();
     241          906 :             const auto& limits = decode_limits_ ? *decode_limits_
     242          438 :                                : host && host->get_decode_limits() ? *host->get_decode_limits()
     243          441 :                                : req.get_body_settings().limits;
     244          459 :             json = req.body_format_->decode(body, *req.body_media_type_, limits);
     245          116 :         } catch (const body_decode_error& e) {
     246          339 :             res.error(e.status(), e.what());
     247          113 :             return false;
     248          116 :         } catch (const std::exception& e) {
     249            3 :             LOG_DEBUG("Body decoder {} failed on route {}: {}", req.body_format_->name, pattern_, e.what());
     250            3 :             res.error(http_response::status::bad_request, "Invalid " + req.body_format_->name + " body");
     251            3 :             return false;
     252            3 :         }
     253              :     }
     254              : #ifdef THINGER_HTTP_VALIJSON_ENABLED
     255          360 :     if (!validate_json(json, res)) return false;
     256              : #endif
     257          254 :     return true;
     258              : }
     259              : 
     260        25271 : thinger::awaitable<void> route::handle_request_coro(request& req, response& res) const {
     261              :     if (decodes_body_) {
     262              :         req.json_body_ = nullptr;
     263              :         if (!decode_body(req, res, req.json_body_)) co_return;
     264              :     }
     265              :     if (sync_callback_) {
     266              :         sync_callback_(req, res);
     267              :     } else {
     268              :         co_await callback_(req, res);
     269              :     }
     270        50542 : }
     271              : 
     272          587 : route& route::schema(const nlohmann::json& json_schema) {
     273          587 :     json_schema_ = json_schema;
     274              : #ifdef THINGER_HTTP_VALIJSON_ENABLED
     275              :     // Make shared components resolvable as #/components/schemas/<name> within the schema
     276          587 :     nlohmann::json root = json_schema_;
     277          587 :     if (schema_components_ && !schema_components_->empty() && root.is_object()) {
     278           19 :         root["components"]["schemas"] = *schema_components_;
     279              :     }
     280          587 :     schema_ = std::make_shared<valijson::Schema>();
     281          587 :     schema_error_.clear();
     282          587 :     has_schema_ = true;
     283          587 :     decodes_body_ = true;
     284          587 :     valijson::SchemaParser parser;
     285          587 :     valijson::adapters::NlohmannJsonAdapter adapter(root);
     286              :     try {
     287          593 :         parser.populateSchema(adapter, *schema_);
     288            3 :     } catch (const std::exception& e) {
     289              :         // Fail closed: requests to the route are rejected rather than accepted unvalidated
     290            3 :         schema_error_ = e.what();
     291            3 :         schema_.reset();
     292            3 :         LOG_ERROR("Invalid JSON Schema for route {}: {}. Its requests will be rejected with 500 "
     293              :                   "(are the schema components it references registered before the route?)",
     294              :                   pattern_, schema_error_);
     295            3 :     }
     296              : #else
     297              :     LOG_WARNING("JSON Schema validation requested but Valijson is not enabled");
     298              : #endif
     299          587 :     return *this;
     300          587 : }
     301              : 
     302              : #ifdef THINGER_HTTP_VALIJSON_ENABLED
     303          360 : bool route::validate_json(const nlohmann::json& json, response& res) const {
     304          360 :     if (!schema_error_.empty()) {
     305            3 :         LOG_ERROR("Rejecting request to route {}: its JSON Schema is invalid ({})", pattern_, schema_error_);
     306            9 :         res.error(http_response::status::internal_server_error, "Invalid route schema");
     307            3 :         return false;
     308              :     }
     309          357 :     if (!schema_) return true;
     310              : 
     311          216 :     valijson::Validator validator;
     312          216 :     valijson::adapters::NlohmannJsonAdapter adapter(json);
     313          216 :     valijson::ValidationResults results;
     314              : 
     315          216 :     if (validator.validate(*schema_, adapter, &results)) {
     316          113 :         return true;
     317              :     }
     318              : 
     319              :     // Answer the first validation error, with the location of the invalid value as details
     320              :     // (by default: {"error": {"message": ..., "context": [...]}})
     321          103 :     valijson::ValidationResults::Error error;
     322          103 :     std::string message = "Schema validation failed";
     323          103 :     nlohmann::json details = nlohmann::json::object();
     324          103 :     if (results.popError(error)) {
     325          103 :         message = error.description;
     326          103 :         nlohmann::json context = nlohmann::json::array();
     327          274 :         for (const auto& c : error.context) {
     328          171 :             context.push_back(c);
     329              :         }
     330          103 :         if (!context.empty()) {
     331          103 :             details["context"] = std::move(context);
     332              :         }
     333          103 :     }
     334              : 
     335          103 :     res.error(http_response::status::bad_request, message, details);
     336          103 :     return false;
     337          216 : }
     338              : #endif
     339              : 
     340              : } // namespace thinger::http
        

Generated by: LCOV version 2.0-1