OSSIA
Open Scenario System for Interactive Application
Loading...
Searching...
No Matches
http_client_request.hpp
1#pragma once
2#include <ossia/detail/config.hpp>
3
4#include <ossia/detail/fmt.hpp>
6#include <ossia/detail/parse_relax.hpp>
7
8#include <boost/asio.hpp>
9
10#include <utility>
11#include <vector>
12
13namespace ossia::net
14{
15using tcp = boost::asio::ip::tcp;
16
17// Verbs are case-sensitive on the wire, but a script may well send "head":
18// comparing ASCII-case-insensitively keeps a bodiless reply from being taken
19// for a truncated one. `upper` is expected to be made of letters only.
20inline bool http_verb_is(std::string_view verb, std::string_view upper) noexcept
21{
22 if(verb.size() != upper.size())
23 return false;
24 for(std::size_t i = 0; i < verb.size(); ++i)
25 if((verb[i] | 0x20) != (upper[i] | 0x20))
26 return false;
27 return true;
28}
29
30// Full HTTP client supporting all methods, custom headers, request body.
31// Success callback receives (request, status_code, response_body).
32// Error callback receives (request, error_message).
33template <typename Fun, typename Err>
34class http_client_request
35 : public std::enable_shared_from_this<http_client_request<Fun, Err>>
36{
37 fmt::memory_buffer m_request;
38
39public:
40 using std::enable_shared_from_this<http_client_request<Fun, Err>>::shared_from_this;
41
42 http_client_request(
43 Fun f, Err err, boost::asio::io_context& ctx, std::string_view verb,
44 std::string_view host, std::string_view path,
45 const std::vector<std::pair<std::string, std::string>>& headers = {},
46 std::string_view body = {})
47 : m_resolver(ctx)
48 , m_socket(ctx)
49 , m_headRequest{http_verb_is(verb, "HEAD")}
50 , m_connectRequest{http_verb_is(verb, "CONNECT")}
51 , m_fun{std::move(f)}
52 , m_err{std::move(err)}
53 {
54 m_request.reserve(256 + host.size() + path.size() + body.size());
55 m_response.prepare(Fun::reserve_expect);
56
57 // Request line: VERB /path HTTP/1.1
58 fmt::format_to(fmt::appender(m_request), "{} ", verb);
59 for(auto c : path)
60 {
61 if(c != ' ')
62 fmt::format_to(fmt::appender(m_request), "{}", c);
63 else
64 fmt::format_to(fmt::appender(m_request), "%20");
65 }
66 fmt::format_to(fmt::appender(m_request), " HTTP/1.1\r\n");
67
68 // Host header (always required)
69 fmt::format_to(fmt::appender(m_request), "Host: {}\r\n", host);
70
71 // Track which default headers the user already provided
72 bool hasAccept = false;
73 bool hasConnection = false;
74 bool hasContentLength = false;
75 bool hasContentType = false;
76
77 // User-supplied headers
78 for(const auto& [key, value] : headers)
79 {
80 fmt::format_to(fmt::appender(m_request), "{}: {}\r\n", key, value);
81 if(key == "Accept")
82 hasAccept = true;
83 else if(key == "Connection")
84 hasConnection = true;
85 else if(key == "Content-Length")
86 hasContentLength = true;
87 else if(key == "Content-Type")
88 hasContentType = true;
89 }
90
91 // Fill in defaults for headers the user didn't set
92 if(!hasAccept)
93 fmt::format_to(fmt::appender(m_request), "Accept: */*\r\n");
94 if(!hasConnection)
95 fmt::format_to(fmt::appender(m_request), "Connection: close\r\n");
96
97 if(!body.empty())
98 {
99 if(!hasContentLength)
100 fmt::format_to(
101 fmt::appender(m_request), "Content-Length: {}\r\n", body.size());
102 if(!hasContentType)
103 fmt::format_to(
104 fmt::appender(m_request), "Content-Type: application/octet-stream\r\n");
105 }
106
107 // End of headers + body
108 fmt::format_to(fmt::appender(m_request), "\r\n");
109 if(!body.empty())
110 fmt::format_to(fmt::appender(m_request), "{}", body);
111 }
112
113 void resolve(const std::string& server, const std::string& port)
114 {
115 m_resolver.async_resolve(
116 server, port,
117 [self = this->shared_from_this()](
118 const boost::system::error_code& err,
119 const tcp::resolver::results_type& endpoints) {
120 self->handle_resolve(err, endpoints);
121 });
122 }
123
124 void close() { m_socket.close(); }
125
126private:
127 void handle_resolve(
128 const boost::system::error_code& err,
129 const tcp::resolver::results_type& endpoints)
130 {
131 if(!err)
132 {
133 boost::asio::async_connect(
134 m_socket, endpoints,
135 [self = this->shared_from_this()](
136 const boost::system::error_code& err, auto&&...) {
137 self->handle_connect(err);
138 });
139 }
140 else
141 {
142 ossia::logger().error("HTTP Error: {}", err.message());
143 m_err(*this, err.message());
144 }
145 }
146
147 void handle_connect(const boost::system::error_code& err)
148 {
149 if(!err)
150 {
151 boost::asio::const_buffer request(m_request.data(), m_request.size());
152 boost::asio::async_write(
153 m_socket, request,
154 [self = this->shared_from_this()](
155 const boost::system::error_code& err, std::size_t size) {
156 self->handle_write_request(err, size);
157 });
158 }
159 else
160 {
161 ossia::logger().error("HTTP Error: {}", err.message());
162 m_err(*this, err.message());
163 }
164 }
165
166 void handle_write_request(const boost::system::error_code& err, std::size_t size)
167 {
168 if(!err)
169 {
170 boost::asio::async_read_until(
171 m_socket, m_response, "\r\n",
172 [self = this->shared_from_this()](
173 const boost::system::error_code& err, std::size_t size) {
174 self->handle_read_status_line(err, size);
175 });
176 }
177 else
178 {
179 ossia::logger().error("HTTP Error: {}", err.message());
180 m_err(*this, err.message());
181 }
182 }
183
184 void handle_read_status_line(const boost::system::error_code& err, std::size_t size)
185 {
186 if(!err || err == boost::asio::error::eof)
187 {
188 std::istream response_stream(&m_response);
189 std::string http_version;
190 response_stream >> http_version;
191 response_stream >> m_statusCode;
192 std::string status_message;
193 std::getline(response_stream, status_message);
194
195 if(!response_stream || http_version.substr(0, 5) != "HTTP/")
196 {
197 ossia::logger().error("HTTP Error: Invalid response");
198 m_err(*this, "Invalid HTTP response");
199 return;
200 }
201
202 read_header_line();
203 }
204 else
205 {
206 ossia::logger().error("HTTP Error: {}", err.message());
207 m_err(*this, err.message());
208 }
209 }
210
211 // Headers are read one field at a time: the status line's CRLF has already
212 // been consumed, so a response carrying no header field at all has only the
213 // empty line left and waiting for "\r\n\r\n" would never complete.
214 void read_header_line()
215 {
216 boost::asio::async_read_until(
217 m_socket, m_response, "\r\n",
218 [self = this->shared_from_this()](
219 const boost::system::error_code& err, std::size_t size) {
220 self->handle_read_header_line(err, size);
221 });
222 }
223
224 void handle_read_header_line(const boost::system::error_code& err, std::size_t size)
225 {
226 if(err && err != boost::asio::error::eof)
227 {
228 ossia::logger().error("HTTP Error: {}", err.message());
229 m_err(*this, err.message());
230 return;
231 }
232
233 std::istream response_stream(&m_response);
234 std::string header;
235 if(!std::getline(response_stream, header))
236 {
237 ossia::logger().error("HTTP Error: response ended inside its headers");
238 m_err(*this, "HTTP response ended inside its headers");
239 return;
240 }
241
242 if(!(header.empty() || header == "\r"))
243 {
244 if(header.starts_with("Content-Length: "))
245 {
246 std::string_view sz(header.begin() + strlen("Content-Length: "), header.end());
247 if(auto num = ossia::parse_relax<int>(sz))
248 m_contentLength = *num;
249 }
250 read_header_line();
251 return;
252 }
253
254 handle_headers_complete(size);
255 }
256
257 void handle_headers_complete(std::size_t size)
258 {
259 // 1xx are interim: the real response follows on the same connection
260 // (RFC 9110 §15.2), and whatever is already buffered belongs to it.
261 if(m_statusCode >= 100 && m_statusCode < 200)
262 {
263 m_statusCode = 0;
264 m_contentLength = -1;
265 boost::asio::async_read_until(
266 m_socket, m_response, "\r\n",
267 [self = this->shared_from_this()](
268 const boost::system::error_code& err, std::size_t size) {
269 self->handle_read_status_line(err, size);
270 });
271 return;
272 }
273
274 // HEAD replies, 204, 304 and a successful CONNECT never carry a body: their
275 // Content-Length, if any, describes what a GET would have returned.
276 const bool bodiless
277 = m_headRequest || m_statusCode == 204 || m_statusCode == 304
278 || (m_connectRequest && m_statusCode >= 200 && m_statusCode < 300);
279 if(bodiless)
280 {
281 // Anything that follows belongs to no response of ours
282 m_response.consume(m_response.size());
283 m_contentLength = 0;
284 finish_read(boost::asio::error::eof, 0);
285 }
286 else if(m_contentLength == 0)
287 {
288 finish_read(boost::asio::error::eof, 0);
289 }
290 else if(m_contentLength > 0)
291 {
292 if(m_contentLength <= (int)m_response.size())
293 {
294 finish_read(boost::asio::error::eof, size);
295 }
296 else
297 {
298 boost::asio::async_read(
299 m_socket, m_response,
300 boost::asio::transfer_exactly(m_contentLength - m_response.size()),
301 [self = this->shared_from_this()](
302 const boost::system::error_code& err, std::size_t size) {
303 self->handle_read_content(err, size);
304 });
305 }
306 }
307 else
308 {
309 // No Content-Length — read until EOF
310 boost::asio::async_read(
311 m_socket, m_response, boost::asio::transfer_all(),
312 [self = this->shared_from_this()](
313 const boost::system::error_code& err, std::size_t size) {
314 self->handle_read_content(err, size);
315 });
316 }
317 }
318
319 void handle_read_content(const boost::system::error_code& err, std::size_t size)
320 {
321 if(!err)
322 {
323 finish_read(err, size);
324 }
325 else if(err == boost::asio::error::eof)
326 {
327 // EOF only delimits the body when no Content-Length was advertised:
328 // otherwise the peer closed before sending the announced payload.
329 if(m_contentLength > 0 && (int)m_response.size() < m_contentLength)
330 {
331 ossia::logger().error("HTTP Error: response truncated before Content-Length");
332 m_err(*this, "HTTP response truncated before Content-Length");
333 return;
334 }
335 finish_read(err, size);
336 }
337 else
338 {
339 ossia::logger().error("HTTP Error: {}", err.message());
340 m_err(*this, err.message());
341 }
342 }
343
344 void finish_read(const boost::system::error_code& err, std::size_t size)
345 {
346 const auto& dat = m_response.data();
347 auto begin = boost::asio::buffers_begin(dat);
348 auto end = boost::asio::buffers_end(dat);
349 auto sz = end - begin;
350
351 // Content-Length is an upper bound as well: bytes read past it belong to
352 // whatever follows on the connection, never to this body.
353 if(m_contentLength >= 0 && sz > (std::ptrdiff_t)m_contentLength)
354 {
355 sz = m_contentLength;
356 end = begin + sz;
357 }
358
359 std::string str;
360 str.reserve(sz + 16);
361 str.assign(begin, end);
362 m_fun(*this, m_statusCode, str);
363 close();
364 }
365
366 tcp::resolver m_resolver;
367 tcp::socket m_socket;
368 boost::asio::streambuf m_response;
369 int m_contentLength{-1};
370 bool m_headRequest{};
371 bool m_connectRequest{};
372 int m_statusCode{0};
373 Fun m_fun;
374 Err m_err;
375};
376}
spdlog::logger & logger() noexcept
Where the errors will be logged. Default is stderr.
Definition context.cpp:120