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
|