1// Copyright (C) 2021 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
4#include "testhttpserver_p.h"
5#include <QTcpSocket>
6#include <QDebug>
7#include <QFile>
8#include <QTimer>
9#include <QTest>
10#include <QQmlFile>
11
12QT_BEGIN_NAMESPACE
13
14/*!
15\internal
16\class TestHTTPServer
17\brief provides a very, very basic HTTP server for testing.
18
19Inside the test case, an instance of TestHTTPServer should be created, with the
20appropriate port to listen on. The server will listen on the localhost interface.
21
22Directories to serve can then be added to server, which will be added as "roots".
23Each root can be added as a Normal, Delay or Disconnect root. Requests for files
24within a Normal root are returned immediately. Request for files within a Delay
25root are delayed for 500ms, and then served. Requests for files within a Disconnect
26directory cause the server to disconnect immediately. A request for a file that isn't
27found in any root will return a 404 error.
28
29If you have the following directory structure:
30
31\code
32disconnect/disconnectTest.qml
33files/main.qml
34files/Button.qml
35files/content/WebView.qml
36slowFiles/slowMain.qml
37\endcode
38it can be added like this:
39\code
40TestHTTPServer server;
41QVERIFY2(server.listen(14445), qPrintable(server.errorString()));
42server.serveDirectory("disconnect", TestHTTPServer::Disconnect);
43server.serveDirectory("files");
44server.serveDirectory("slowFiles", TestHTTPServer::Delay);
45\endcode
46
47The following request urls will then result in the appropriate action:
48\table
49\header \li URL \li Action
50\row \li http://localhost:14445/disconnectTest.qml \li Disconnection
51\row \li http://localhost:14445/main.qml \li main.qml returned immediately
52\row \li http://localhost:14445/Button.qml \li Button.qml returned immediately
53\row \li http://localhost:14445/content/WebView.qml \li content/WebView.qml returned immediately
54\row \li http://localhost:14445/slowMain.qml \li slowMain.qml returned after 500ms
55\endtable
56*/
57
58static QList<QByteArrayView> ignoredHeaders = {
59 "HTTP2-Settings", // We ignore this
60 "Upgrade", // We ignore this as well
61};
62
63static QUrl localHostUrl(quint16 port)
64{
65 QUrl url;
66 url.setScheme(QStringLiteral("http"));
67 url.setHost(QStringLiteral("127.0.0.1"));
68 url.setPort(port);
69 return url;
70}
71
72TestHTTPServer::TestHTTPServer()
73 : m_state(AwaitingHeader)
74{
75 QObject::connect(sender: &m_server, signal: &QTcpServer::newConnection, context: this, slot: &TestHTTPServer::newConnection);
76}
77
78bool TestHTTPServer::listen()
79{
80 return m_server.listen(address: QHostAddress::LocalHost, port: 0);
81}
82
83QUrl TestHTTPServer::baseUrl() const
84{
85 return localHostUrl(port: m_server.serverPort());
86}
87
88quint16 TestHTTPServer::port() const
89{
90 return m_server.serverPort();
91}
92
93QUrl TestHTTPServer::url(const QString &documentPath) const
94{
95 return baseUrl().resolved(relative: documentPath);
96}
97
98QString TestHTTPServer::urlString(const QString &documentPath) const
99{
100 return url(documentPath).toString();
101}
102
103QString TestHTTPServer::errorString() const
104{
105 return m_server.errorString();
106}
107
108bool TestHTTPServer::serveDirectory(const QString &dir, Mode mode)
109{
110 m_directories.append(t: qMakePair(value1: dir, value2&: mode));
111 return true;
112}
113
114/*
115 Add an alias, so that if filename is requested and does not exist,
116 alias may be returned.
117*/
118void TestHTTPServer::addAlias(const QString &filename, const QString &alias)
119{
120 m_aliases.insert(key: filename, value: alias);
121}
122
123void TestHTTPServer::addRedirect(const QString &filename, const QString &redirectName)
124{
125 m_redirects.insert(key: filename, value: redirectName);
126}
127
128void TestHTTPServer::registerFileNameForContentSubstitution(const QString &fileName)
129{
130 m_contentSubstitutedFileNames.insert(value: fileName);
131}
132
133bool TestHTTPServer::wait(const QUrl &expect, const QUrl &reply, const QUrl &body)
134{
135 m_state = AwaitingHeader;
136 m_data.clear();
137
138 QFile expectFile(QQmlFile::urlToLocalFileOrQrc(expect));
139 if (!expectFile.open(flags: QIODevice::ReadOnly))
140 return false;
141
142 QFile replyFile(QQmlFile::urlToLocalFileOrQrc(reply));
143 if (!replyFile.open(flags: QIODevice::ReadOnly))
144 return false;
145
146 m_bodyData = QByteArray();
147 if (body.isValid()) {
148 QFile bodyFile(QQmlFile::urlToLocalFileOrQrc(body));
149 if (!bodyFile.open(flags: QIODevice::ReadOnly))
150 return false;
151 m_bodyData = bodyFile.readAll();
152 }
153
154 const QByteArray serverHostUrl
155 = QByteArrayLiteral("127.0.0.1:")+ QByteArray::number(m_server.serverPort());
156
157 QByteArray line;
158 bool headers_done = false;
159 while (!(line = expectFile.readLine()).isEmpty()) {
160 line.replace(before: '\r', after: "");
161 if (line.at(i: 0) == '\n') {
162 headers_done = true;
163 continue;
164 }
165 if (headers_done) {
166 m_waitData.body.append(a: line);
167 } else if (line.endsWith(bv: "{{Ignore}}\n")) {
168 m_waitData.headerPrefixes.append(t: line.left(len: line.size() - strlen(s: "{{Ignore}}\n")));
169 } else {
170 line.replace(before: "{{ServerHostUrl}}", after: serverHostUrl);
171 m_waitData.headerExactMatches.append(t: line);
172 }
173 }
174
175 m_replyData = replyFile.readAll();
176
177 if (!m_replyData.endsWith(c: '\n'))
178 m_replyData.append(c: '\n');
179 m_replyData.append(s: "Content-length: ");
180 m_replyData.append(a: QByteArray::number(m_bodyData.size()));
181 m_replyData.append(s: "\n\n");
182
183 for (int ii = 0; ii < m_replyData.size(); ++ii) {
184 if (m_replyData.at(i: ii) == '\n' && (!ii || m_replyData.at(i: ii - 1) != '\r')) {
185 m_replyData.insert(i: ii, c: '\r');
186 ++ii;
187 }
188 }
189 m_replyData.append(a: m_bodyData);
190
191 return true;
192}
193
194bool TestHTTPServer::hasFailed() const
195{
196 return m_state == Failed;
197}
198
199void TestHTTPServer::newConnection()
200{
201 QTcpSocket *socket = m_server.nextPendingConnection();
202 if (!socket)
203 return;
204
205 if (!m_directories.isEmpty())
206 m_dataCache.insert(key: socket, value: QByteArray());
207
208 QObject::connect(sender: socket, signal: &QAbstractSocket::disconnected, context: this, slot: &TestHTTPServer::disconnected);
209 QObject::connect(sender: socket, signal: &QIODevice::readyRead, context: this, slot: &TestHTTPServer::readyRead);
210}
211
212void TestHTTPServer::disconnected()
213{
214 QTcpSocket *socket = qobject_cast<QTcpSocket *>(object: sender());
215 if (!socket)
216 return;
217
218 m_dataCache.remove(key: socket);
219 for (int ii = 0; ii < m_toSend.size(); ++ii) {
220 if (m_toSend.at(i: ii).first == socket) {
221 m_toSend.removeAt(i: ii);
222 --ii;
223 }
224 }
225 socket->disconnect();
226 socket->deleteLater();
227}
228
229void TestHTTPServer::readyRead()
230{
231 QTcpSocket *socket = qobject_cast<QTcpSocket *>(object: sender());
232 if (!socket || socket->state() == QTcpSocket::ClosingState)
233 return;
234
235 if (!m_directories.isEmpty()) {
236 serveGET(socket, socket->readAll());
237 return;
238 }
239
240 if (m_state == Failed || (m_waitData.body.isEmpty() && m_waitData.headerExactMatches.size() == 0)) {
241 qWarning() << "TestHTTPServer: Unexpected data" << socket->readAll();
242 return;
243 }
244
245 if (m_state == AwaitingHeader) {
246 QByteArray line;
247 while (!(line = socket->readLine()).isEmpty()) {
248 line.replace(before: '\r', after: "");
249 if (line.at(i: 0) == '\n') {
250 m_state = AwaitingData;
251 m_data += socket->readAll();
252 break;
253 } else {
254 bool prefixFound = false;
255 for (const QByteArray &prefix : m_waitData.headerPrefixes) {
256 if (line.startsWith(bv: prefix)) {
257 prefixFound = true;
258 break;
259 }
260 }
261 for (QByteArrayView ignore : ignoredHeaders) {
262 if (line.startsWith(bv: ignore)) {
263 prefixFound = true;
264 break;
265 }
266 }
267
268 if (!prefixFound && !m_waitData.headerExactMatches.contains(t: line)) {
269 qWarning() << "TestHTTPServer: Unexpected header:" << line
270 << "\nExpected exact headers: " << m_waitData.headerExactMatches
271 << "\nExpected header prefixes: " << m_waitData.headerPrefixes;
272 m_state = Failed;
273 socket->disconnectFromHost();
274 return;
275 }
276 }
277 }
278 } else {
279 m_data += socket->readAll();
280 }
281
282 if (!m_data.isEmpty() || m_waitData.body.isEmpty()) {
283 if (m_waitData.body != m_data) {
284 qWarning() << "TestHTTPServer: Unexpected data" << m_data << "\nExpected: " << m_waitData.body;
285 m_state = Failed;
286 } else {
287 socket->write(data: m_replyData);
288 }
289 socket->disconnectFromHost();
290 }
291}
292
293bool TestHTTPServer::reply(QTcpSocket *socket, const QByteArray &fileNameIn)
294{
295 const QString fileName = QLatin1String(fileNameIn);
296 if (m_redirects.contains(key: fileName)) {
297 const QByteArray response
298 = "HTTP/1.1 302 Found\r\nContent-length: 0\r\nContent-type: text/html; charset=UTF-8\r\nLocation: "
299 + m_redirects.value(key: fileName).toUtf8() + "\r\n\r\n";
300 socket->write(data: response);
301 return true;
302 }
303
304 for (int ii = 0; ii < m_directories.size(); ++ii) {
305 const QString &dir = m_directories.at(i: ii).first;
306 const Mode mode = m_directories.at(i: ii).second;
307
308 QString dirFile = dir + QLatin1Char('/') + fileName;
309
310 if (!QFile::exists(fileName: dirFile)) {
311 const QHash<QString, QString>::const_iterator it = m_aliases.constFind(key: fileName);
312 if (it != m_aliases.constEnd())
313 dirFile = dir + QLatin1Char('/') + it.value();
314 }
315
316 QFile file(dirFile);
317 if (file.open(flags: QIODevice::ReadOnly)) {
318
319 if (mode == Disconnect)
320 return true;
321
322 QByteArray data = file.readAll();
323 if (m_contentSubstitutedFileNames.contains(value: QLatin1Char('/') + fileName))
324 data.replace(QByteArrayLiteral("{{ServerBaseUrl}}"), after: baseUrl().toString().toUtf8());
325
326 QByteArray response
327 = "HTTP/1.0 200 OK\r\nContent-type: text/html; charset=UTF-8\r\nContent-length: ";
328 response += QByteArray::number(data.size());
329 response += "\r\n\r\n";
330 response += data;
331
332 if (mode == Delay) {
333 m_toSend.append(t: qMakePair(value1&: socket, value2&: response));
334 QTimer::singleShot(interval: 500, receiver: this, slot: &TestHTTPServer::sendOne);
335 return false;
336 } else {
337 socket->write(data: response);
338 return true;
339 }
340 }
341 }
342
343 socket->write(data: "HTTP/1.0 404 Not found\r\nContent-type: text/html; charset=UTF-8\r\n\r\n");
344
345 return true;
346}
347
348void TestHTTPServer::sendDelayedItem()
349{
350 sendOne();
351}
352
353void TestHTTPServer::sendOne()
354{
355 if (!m_toSend.isEmpty()) {
356 m_toSend.first().first->write(data: m_toSend.first().second);
357 m_toSend.first().first->close();
358 m_toSend.removeFirst();
359 }
360}
361
362void TestHTTPServer::serveGET(QTcpSocket *socket, const QByteArray &data)
363{
364 const QHash<QTcpSocket *, QByteArray>::iterator it = m_dataCache.find(key: socket);
365 if (it == m_dataCache.end())
366 return;
367
368 QByteArray &total = it.value();
369 total.append(a: data);
370
371 if (total.contains(bv: "\n\r\n")) {
372 bool close = true;
373 if (total.startsWith(bv: "GET /")) {
374 const int space = total.indexOf(c: ' ', from: 4);
375 if (space != -1)
376 close = reply(socket, fileNameIn: total.mid(index: 5, len: space - 5));
377 }
378 m_dataCache.erase(it);
379 if (close)
380 socket->disconnectFromHost();
381 }
382}
383
384ThreadedTestHTTPServer::ThreadedTestHTTPServer(const QString &dir, TestHTTPServer::Mode mode) :
385 m_port(0)
386{
387 m_dirs[dir] = mode;
388 start();
389}
390
391ThreadedTestHTTPServer::ThreadedTestHTTPServer(const QHash<QString, TestHTTPServer::Mode> &dirs) :
392 m_dirs(dirs), m_port(0)
393{
394 start();
395}
396
397ThreadedTestHTTPServer::~ThreadedTestHTTPServer()
398{
399 quit();
400 wait();
401}
402
403QUrl ThreadedTestHTTPServer::baseUrl() const
404{
405 return localHostUrl(port: m_port);
406}
407
408QUrl ThreadedTestHTTPServer::url(const QString &documentPath) const
409{
410 return baseUrl().resolved(relative: documentPath);
411}
412
413QString ThreadedTestHTTPServer::urlString(const QString &documentPath) const
414{
415 return url(documentPath).toString();
416}
417
418void ThreadedTestHTTPServer::run()
419{
420 TestHTTPServer server;
421 {
422 QMutexLocker locker(&m_mutex);
423 QVERIFY2(server.listen(), qPrintable(server.errorString()));
424 m_port = server.port();
425 for (QHash<QString, TestHTTPServer::Mode>::ConstIterator i = m_dirs.constBegin();
426 i != m_dirs.constEnd(); ++i) {
427 server.serveDirectory(dir: i.key(), mode: i.value());
428 }
429 m_condition.wakeAll();
430 }
431 exec();
432}
433
434void ThreadedTestHTTPServer::start()
435{
436 QMutexLocker locker(&m_mutex);
437 QThread::start();
438 m_condition.wait(lockedMutex: &m_mutex);
439}
440
441QT_END_NAMESPACE
442
443#include "moc_testhttpserver_p.cpp"
444

source code of qtdeclarative/src/quicktestutils/qml/testhttpserver.cpp