Line data Source code
1 : #ifndef THINGER_HTTP_ROUTE_DESCRIPTOR_HPP
2 : #define THINGER_HTTP_ROUTE_DESCRIPTOR_HPP
3 :
4 : #include <chrono>
5 : #include <concepts>
6 : #include <cstdint>
7 : #include <type_traits>
8 : #include <map>
9 : #include <functional>
10 : #include <initializer_list>
11 : #include <regex>
12 : #include <string>
13 : #include <vector>
14 : #include <memory>
15 : #include <optional>
16 : #include "../request.hpp"
17 : #include "../body_decoders.hpp"
18 : #include "../../../util/types.hpp"
19 :
20 : #ifdef THINGER_HTTP_VALIJSON_ENABLED
21 : #include <valijson/schema.hpp>
22 : #include <valijson/schema_parser.hpp>
23 : #include <valijson/validator.hpp>
24 : #include <valijson/validation_results.hpp>
25 : #include <valijson/adapters/nlohmann_json_adapter.hpp>
26 : #endif
27 :
28 : namespace thinger::http {
29 :
30 : // Route parameters syntax:
31 : // 1. Simple parameters: :param_name
32 : // Example: "/api/v1/users/:user/devices/:device"
33 : // Matches any non-empty text without slashes
34 : //
35 : // 2. Parameters with custom regex: :param_name(regex)
36 : // Example: "/api/v1/users/:id([0-9]+)" - numeric ID only
37 : // Example: "/api/v1/users/:user([a-zA-Z0-9_-]{1,32})" - alphanumeric with length limit
38 : // Example: "/files/:path(.+)" - match everything including slashes
39 : // The regex extends to the matching closing parenthesis, so it may contain groups.
40 : //
41 : // Matching (see route_tree): the path is matched segment by segment (the text between
42 : // slashes), with this priority whatever the registration order: static segments, then
43 : // segments with a regex constraint (or mixing text and parameters, as ":name.:ext"), then
44 : // simple parameters, then parameters that may match slashes (as ":path(.+)"), which take
45 : // the rest of the path. If the rest of the path does not match below the chosen option,
46 : // the next one is tried. Different constraints at the same position are tried in the
47 : // order they were first registered there, and wildcards in registration order. Registering
48 : // a route with the same structure as another one logs a warning, and so does adding a
49 : // constraint (or wildcard) next to one that may overlap with it (a cheap check: unless
50 : // they cannot start with the same character). A parameter regex that
51 : // depends on what surrounds it (anchors ^ $, word boundaries, lookaheads, back-references)
52 : // makes its route match the whole path with its regex, after all the other options.
53 : // - The path is matched as received, before percent-decoding: "%2F" is not a slash, and
54 : // parameters get the encoded text ("/files/:name" on "/files/a%20b" gives "a%20b").
55 : // - Trailing slashes and empty segments are significant: "/users" does not match
56 : // "/users/", and a parameter does not match an empty segment ("/users//devices").
57 : //
58 : // Common patterns:
59 : #define ID_PATTERN "[0-9]+" // Numeric ID
60 : #define ALPHANUM_ID "[a-zA-Z0-9_-]{1,32}" // Alphanumeric ID (1-32 chars)
61 : #define UUID_PATTERN "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
62 : #define EMAIL_PATTERN "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
63 : #define SLUG_PATTERN "[a-z0-9]+(?:-[a-z0-9]+)*" // URL-friendly slug
64 :
65 : // Forward declarations
66 : class request;
67 : class response;
68 :
69 : // Callback types for route handlers - supporting multiple signatures
70 : using route_callback_response_only = std::function<void(response&)>;
71 : using route_callback_json_response = std::function<void(nlohmann::json&, response&)>;
72 : using route_callback_request_response = std::function<void(request&, response&)>;
73 : using route_callback_request_json_response = std::function<void(request&, nlohmann::json&, response&)>;
74 : // What every route callback is turned into: a coroutine taking (request&, response&)
75 : using route_callback_awaitable = std::function<thinger::awaitable<void>(request&, response&)>;
76 :
77 : // Route callback signatures, synchronous or coroutines returning awaitable<void>. Those
78 : // taking a JSON body get it read, decoded (from JSON or the other types the route accepts,
79 : // see route::accepts) and validated against the route schema before they run; coroutines
80 : // taking (request&, response&) or (response&) read the body themselves (deferred body)
81 : // unless deferred_body(false) is set on the route.
82 : template<typename F>
83 : concept request_response_callback = std::invocable<F&, request&, response&>;
84 :
85 : template<typename F>
86 : concept response_callback = !request_response_callback<F> && std::invocable<F&, response&>;
87 :
88 : template<typename F>
89 : concept request_json_callback = std::invocable<F&, request&, nlohmann::json&, response&>;
90 :
91 : // Generic lambdas taking two arguments are treated as (request&, response&)
92 : template<typename F>
93 : concept json_callback = !request_response_callback<F> && std::invocable<F&, nlohmann::json&, response&>;
94 :
95 : // Callbacks that do not take the JSON body (they may read the body themselves), as the
96 : // not found handlers
97 : template<typename F>
98 : concept request_handler_callable = request_response_callback<F> || response_callback<F>;
99 :
100 : template<typename F>
101 : concept route_callable = request_handler_callable<F> || request_json_callback<F> || json_callback<F>;
102 :
103 : // The same signatures, as coroutines
104 : template<typename F, typename... Args>
105 : concept returns_awaitable = std::same_as<std::invoke_result_t<F&, Args...>, thinger::awaitable<void>>;
106 :
107 : template<typename F>
108 : concept awaitable_handler = request_response_callback<F> && returns_awaitable<F, request&, response&>;
109 :
110 : template<typename F>
111 : concept awaitable_response_handler = response_callback<F> && returns_awaitable<F, response&>;
112 :
113 : template<typename F>
114 : concept awaitable_request_json_handler = request_json_callback<F>
115 : && returns_awaitable<F, request&, nlohmann::json&, response&>;
116 :
117 : template<typename F>
118 : concept awaitable_json_handler = json_callback<F> && returns_awaitable<F, nlohmann::json&, response&>;
119 :
120 : namespace detail {
121 :
122 : using thinger::is_awaitable_v;
123 :
124 : template<typename T>
125 : inline constexpr bool is_std_function_v = false;
126 :
127 : template<typename R, typename... Args>
128 : inline constexpr bool is_std_function_v<std::function<R(Args...)>> = true;
129 :
130 : // Whether a callback is empty: a null std::function or function pointer
131 : template<typename F>
132 848 : bool is_empty_callback(const F& callback) {
133 : using type = std::remove_cvref_t<F>;
134 : if constexpr (is_std_function_v<type> || std::is_pointer_v<type>) {
135 9 : return !callback;
136 : } else {
137 839 : return false;
138 : }
139 : }
140 :
141 : template<typename F, typename... Args>
142 154 : thinger::awaitable<void> call_synchronously(F& callback, Args&... args) {
143 : callback(args...);
144 : co_return;
145 308 : }
146 :
147 : // Call a route callback as a coroutine: coroutines as they are, synchronous callbacks
148 : // from one (their result, if any, is ignored)
149 : template<typename F, typename... Args>
150 952 : thinger::awaitable<void> call_callback(F& callback, Args&... args) {
151 : using result = std::invoke_result_t<F&, Args&...>;
152 : if constexpr (std::same_as<result, thinger::awaitable<void>>) {
153 798 : return callback(args...);
154 : } else {
155 : static_assert(!is_awaitable_v<result>, "coroutine route callbacks must return thinger::awaitable<void>");
156 154 : return call_synchronously(callback, args...);
157 : }
158 : }
159 :
160 : }
161 :
162 : // A callback taking (request&, response&) or (response&), as the coroutine every route
163 : // callback is turned into; empty if the callback is (a null std::function or pointer)
164 : template<request_handler_callable F>
165 848 : route_callback_awaitable make_route_callback(F&& callback) {
166 848 : if (detail::is_empty_callback(callback)) return nullptr;
167 : if constexpr (request_response_callback<F>) {
168 2177 : return [callback = std::forward<F>(callback)](request& req, response& res) mutable {
169 862 : return detail::call_callback(callback, req, res);
170 701 : };
171 : } else {
172 360 : return [callback = std::forward<F>(callback)](request&, response& res) mutable {
173 90 : return detail::call_callback(callback, res);
174 138 : };
175 : }
176 : }
177 :
178 : // Documented request parameter (path or query), for API documentation
179 : struct route_parameter {
180 : std::string name;
181 : std::string in; // "path" or "query"
182 : std::string description;
183 : nlohmann::json schema; // JSON Schema of the value
184 : bool required = false;
185 : };
186 :
187 : // Documented response for a status code, for API documentation
188 : struct route_response {
189 : std::string description;
190 : nlohmann::json schema; // JSON Schema of the body (null if none)
191 : nlohmann::json example; // example body (null if none)
192 : };
193 :
194 : class route {
195 : public:
196 : route(const std::string& pattern);
197 :
198 : // Set the callback: any route_callable signature, synchronous or coroutine. It is
199 : // stored as a callable taking (request&, response&), synchronous (called directly,
200 : // without a coroutine frame per request) or coroutine, and whether it takes the JSON body.
201 : template<route_callable F>
202 34250 : route& operator=(F&& callback) {
203 34250 : takes_json_body_ = !request_handler_callable<F>;
204 34250 : decodes_body_ = takes_json_body_ || has_schema_;
205 58475 : auto adapted = [callback = std::forward<F>(callback)](request& req, response& res) mutable -> decltype(auto) {
206 25049 : return invoke_callback(callback, req, res);
207 : };
208 : using result = std::invoke_result_t<decltype(adapted)&, request&, response&>;
209 : if constexpr (std::same_as<result, thinger::awaitable<void>>) {
210 5916 : callback_ = std::move(adapted);
211 5916 : sync_callback_ = nullptr;
212 : // coroutines not taking the JSON body read it themselves; the others get it read first
213 5916 : if (!takes_json_body_) deferred_body_ = true;
214 : } else {
215 : static_assert(!detail::is_awaitable_v<result>, "coroutine route callbacks must return thinger::awaitable<void>");
216 28334 : sync_callback_ = std::move(adapted);
217 28334 : callback_ = nullptr;
218 : }
219 34250 : return *this;
220 2204 : }
221 :
222 : // Deferred body mode - handler reads body at its discretion. Ignored by handlers
223 : // that take the JSON body, and by routes with a schema, which always need it read first.
224 : route& deferred_body(bool enabled = true);
225 25696 : bool is_deferred_body() const { return deferred_body_ && !decodes_body_; }
226 :
227 : // Set JSON Schema for request body validation. The body is read, decoded and validated
228 : // before the callback runs, whatever its signature: a callback taking only (request&,
229 : // response&) or (response&) finds it in req.body(), already validated; one not valid is
230 : // answered as for callbacks taking the JSON body (400, or 415 for a type that cannot be
231 : // decoded). If it cannot be parsed (e.g. a $ref to a schema component not registered
232 : // yet), requests to the route are answered with 500.
233 : route& schema(const nlohmann::json& json_schema);
234 :
235 : // Body types the route accepts: names of body decoders ("json", "msgpack", "cbor",
236 : // "ubjson", "form", "text", "binary", or custom ones, see body_decoder_registry) or media
237 : // types ("application/cbor", "image/png"). A body of another type is answered with 415
238 : // Unsupported Media Type before it is read; a body without Content-Type is taken as
239 : // JSON. Routes taking the body decoded accept, without this, the types of the
240 : // application (http_application::accept_body_types: JSON only by default), and only
241 : // those with a decoder. Routes reading the body themselves accept any type without
242 : // this, or the listed ones, decoder or not (they get the raw body).
243 : route& accepts(std::vector<std::string> types);
244 :
245 : // Types set with accepts(), lowercase; empty if none (the application ones apply)
246 146 : const std::vector<std::string>& get_accepted_types() const { return accepted_types_; }
247 :
248 : // --- Limits (a group sets them on its routes; a route can override them) ---
249 :
250 : // Maximum request body size, instead of the one of its virtual host or the application
251 : // (see virtual_host::max_body_size, http_application::set_max_body_size): a larger body is answered with 413 Payload Too
252 : // Large, a Content-Length one before reading it, whether the server reads it (callbacks
253 : // taking the body decoded, or deferred_body(false)) or the callback does (read_body(),
254 : // read(), read_some(), a reverse proxy). Also once decompressed.
255 : // std::numeric_limits<std::uint64_t>::max() for no limit.
256 : route& max_body_size(std::uint64_t size);
257 :
258 : // Maximum body size set for the route; nullopt if none (the host or application one applies)
259 26381 : const std::optional<std::uint64_t>& get_max_body_size() const { return max_body_size_; }
260 :
261 : // Maximum time from the dispatch of a request (its headers received and the route
262 : // matched) until its response starts, middlewares and body reading included. On expiry
263 : // the request is answered with `status` (504 Gateway Timeout by default, or e.g. 503
264 : // Service Unavailable) through the error formatter, and later answers from the callback
265 : // are dropped. Then the scope of the request is cancelled with cancel_reason::timeout: the
266 : // callback goes on until it ends, unless the route declared cancel_on(timeout), which
267 : // interrupts it (see cancel_on). Responses started
268 : // in time (streams, chunked responses, SSE, WebSocket and other takeovers) are never cut.
269 : // If the body was not read whole, the connection is closed after the response. Unset,
270 : // the timeout of its virtual host applies, if any (see virtual_host::timeout); zero for
271 : // no timeout.
272 : route& timeout(std::chrono::milliseconds timeout,
273 : http_response::status status = http_response::status::gateway_timeout);
274 :
275 : // Timeout set for the route (nullopt if unset, zero for none), and its status
276 26306 : const std::optional<std::chrono::milliseconds>& get_timeout() const { return timeout_; }
277 231 : http_response::status get_timeout_status() const { return timeout_status_; }
278 :
279 : // Maximum time to receive a request, its body read to its end, from its start (its first
280 : // byte), instead of the one of its virtual host or the connection (see
281 : // virtual_host::request_timeout, connection_options::request_timeout): e.g. longer for a
282 : // route receiving large uploads. On expiry its scope is cancelled (cancel_reason::timeout),
283 : // a pending body read fails, and the request is answered 408 Request Timeout unless its
284 : // response started (the connection is closed then); later answers from the callback are
285 : // dropped, and the connection is closed. A request without a body is received with its
286 : // head: it never applies to it (its response time is what timeout() bounds). Zero for no
287 : // deadline.
288 : route& request_timeout(std::chrono::milliseconds timeout);
289 :
290 : // Request deadline set for the route (nullopt if unset, zero for none)
291 25977 : const std::optional<std::chrono::milliseconds>& get_request_timeout() const { return request_timeout_; }
292 :
293 : // The reasons for which the library interrupts the callback (see cancel_reason): when
294 : // the scope of its request is cancelled for one of them, the awaits of the callback end
295 : // with operation_aborted (asio cancels its coroutine; its next co_await throws). None
296 : // by default: the callback goes on and finds the reason in request::cancelled(), or binds
297 : // request::cancellation_slot() to the awaits it can abandon. Shutdown at the drain
298 : // deadline interrupts every callback, declared or not. none is ignored, so {} or {none}
299 : // keep the default. Set on each route: groups and hosts do not carry it. Work that must
300 : // not be interrupted on such a route runs outside the cancellation state of the callback:
301 : // after co_await boost::asio::this_coro::reset_cancellation_state(
302 : // boost::asio::disable_cancellation()) (without a filter, the reset lets the terminal
303 : // cancellation through), or in a coroutine of its own, co_spawned with no slot
304 : // (bind_cancellation_slot(boost::asio::cancellation_slot{}, use_awaitable)). The scope is
305 : // cancelled once: check request::cancelled() afterwards
306 : route& cancel_on(std::initializer_list<cancel_reason> reasons);
307 :
308 : // The reasons set with cancel_on(), as bits 1 << reason (0 for none)
309 25953 : uint8_t get_cancel_on() const { return cancel_on_; }
310 :
311 : // Limits of the body decoded for the callback, instead of those of its virtual host or
312 : // the application (see virtual_host::decode_limits, http_application::set_body_limits)
313 : route& decode_limits(body_limits limits);
314 :
315 : // Decode limits set for the route; nullopt if none (the host or application ones apply)
316 : const std::optional<body_limits>& get_decode_limits() const { return decode_limits_; }
317 :
318 : // --- API documentation (read by tools such as OpenAPI generators) ---
319 : route& summary(const std::string& text);
320 : route& description(const std::string& desc);
321 : route& tag(const std::string& name);
322 : route& tags(const std::vector<std::string>& names);
323 : route& operation_id(const std::string& id);
324 : route& deprecated(bool value = true);
325 : route& path_param(const std::string& name, const std::string& description,
326 : nlohmann::json schema = {{"type", "string"}});
327 : route& query_param(const std::string& name, const std::string& description,
328 : nlohmann::json schema = {{"type", "string"}}, bool required = false);
329 : route& returns(int status, const std::string& description,
330 : nlohmann::json schema = nullptr, nlohmann::json example = nullptr);
331 : route& example(nlohmann::json body);
332 : route& hidden(bool value = true); // leave the route out of the API documentation
333 :
334 154 : const std::string& get_summary() const { return summary_; }
335 131 : const std::string& get_description() const { return description_; }
336 277 : const std::vector<std::string>& get_tags() const { return tags_; }
337 131 : const std::string& get_operation_id() const { return operation_id_; }
338 119 : bool is_deprecated() const { return deprecated_; }
339 302 : const std::vector<route_parameter>& get_param_docs() const { return param_docs_; }
340 125 : const std::map<int, route_response>& get_responses() const { return responses_; }
341 119 : const std::vector<nlohmann::json>& get_examples() const { return examples_; }
342 119 : const nlohmann::json& get_schema() const { return json_schema_; }
343 157 : bool is_hidden() const { return hidden_; }
344 :
345 : // Whether the callback receives the request body decoded as JSON
346 131 : bool takes_json_body() const { return takes_json_body_; }
347 :
348 : // Check the type of the request body (before reading it) against the accepted types:
349 : // false, after answering 415, if it is not accepted. Requests without a body pass.
350 : bool accepts_body(request& req, response& res) const;
351 :
352 : // Shared schemas (OpenAPI components) that route schemas can reference with
353 : // {"$ref": "#/components/schemas/<name>"}; set by the router that owns the route
354 34196 : void set_schema_components(const nlohmann::json* components) { schema_components_ = components; }
355 :
356 : // Custom regex of a path parameter declared as :name(regex), empty if it has none
357 : std::string get_parameter_pattern(const std::string& name) const;
358 :
359 : // --- Free metadata for the application (e.g. the permission a route requires). ---
360 : // The library does not interpret it; middlewares can read it via request::get_matched_route().
361 : route& meta(const std::string& key, nlohmann::json value);
362 : bool has_meta(const std::string& key) const;
363 : const nlohmann::json& get_meta(const std::string& key) const; // null if missing
364 3 : const nlohmann::json& get_metadata() const { return metadata_; }
365 :
366 : // Check if route matches the given path. The regex of the whole pattern is compiled by
367 : // the first call (routing does not use it); calls from several threads are safe
368 : bool matches(const std::string& path, std::smatch& matches) const;
369 :
370 : // Get route parameters from regex
371 131 : const std::vector<std::string>& get_parameters() const { return parameters_; }
372 :
373 : // Handle the request: decode and validate the body for callbacks taking it, then run
374 : // the callback
375 : thinger::awaitable<void> handle_request_coro(request& req, response& res) const;
376 :
377 : // Get the original pattern
378 66588 : const std::string& get_pattern() const { return pattern_; }
379 :
380 : private:
381 : struct lazy_regex;
382 :
383 : std::string pattern_;
384 : std::shared_ptr<lazy_regex> regex_; // shared by the copies of the route
385 : std::vector<std::string> parameters_;
386 : std::map<std::string, std::string> parameter_patterns_;
387 : nlohmann::json json_schema_;
388 : std::vector<std::string> accepted_types_;
389 :
390 : // Limits (unset: those of the virtual host, or the application)
391 : std::optional<std::uint64_t> max_body_size_;
392 : std::optional<std::chrono::milliseconds> timeout_;
393 : http_response::status timeout_status_ = http_response::status::gateway_timeout;
394 : std::optional<std::chrono::milliseconds> request_timeout_;
395 : std::optional<body_limits> decode_limits_;
396 : uint8_t cancel_on_ = 0;
397 :
398 : // Documentation and metadata
399 : std::string summary_;
400 : std::string description_;
401 : std::vector<std::string> tags_;
402 : std::string operation_id_;
403 : bool deprecated_ = false;
404 : std::vector<route_parameter> param_docs_;
405 : std::map<int, route_response> responses_;
406 : std::vector<nlohmann::json> examples_;
407 : bool hidden_ = false;
408 : nlohmann::json metadata_ = nlohmann::json::object();
409 : const nlohmann::json* schema_components_ = nullptr;
410 : bool deferred_body_ = false;
411 : bool takes_json_body_ = false;
412 : bool has_schema_ = false;
413 : // The body is decoded (and validated) before the callback: it takes it, or there is a schema
414 : bool decodes_body_ = false;
415 : // The callback, either synchronous or coroutine (the other one is empty)
416 : route_callback_request_response sync_callback_;
417 : route_callback_awaitable callback_;
418 :
419 : // Call a callback with the arguments it takes; those taking the JSON body get the body
420 : // parsed by handle_request_coro()
421 : template<typename F>
422 25049 : static decltype(auto) invoke_callback(F& callback, request& req, response& res) {
423 : if constexpr (request_response_callback<F>) {
424 9416 : return callback(req, res);
425 : } else if constexpr (response_callback<F>) {
426 15391 : return callback(res);
427 : } else if constexpr (request_json_callback<F>) {
428 51 : return callback(req, req.json_body_, res);
429 : } else {
430 191 : return callback(req.json_body_, res);
431 : }
432 : }
433 :
434 : // Type of the request body against the accepted types (matched once per request, and
435 : // kept in it): false, after answering 415, if it is not accepted
436 : bool match_body_type(request& req, response& res) const;
437 :
438 : // Decode the request body and validate it against the schema; responds with an error
439 : // and returns false if it cannot be decoded or is not valid
440 : bool decode_body(request& req, response& res, nlohmann::json& json) const;
441 :
442 : #ifdef THINGER_HTTP_VALIJSON_ENABLED
443 : std::shared_ptr<valijson::Schema> schema_;
444 : std::string schema_error_; // why the schema could not be parsed (requests are rejected)
445 :
446 : bool validate_json(const nlohmann::json& json, response& res) const;
447 : #endif
448 : };
449 :
450 : } // namespace thinger::http
451 :
452 : #endif // THINGER_HTTP_ROUTE_DESCRIPTOR_HPP
|