libQuotient
A Qt library for building matrix clients
basejob.h
Go to the documentation of this file.
1 // SPDX-FileCopyrightText: 2015 Felix Rohrbach <kde@fxrh.de>
2 // SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net>
3 // SPDX-License-Identifier: LGPL-2.1-or-later
4 
5 #pragma once
6 
7 #include "requestdata.h"
8 
9 #include <QtCore/QObject>
10 #include <QtCore/QStringBuilder>
11 #include <QtCore/QLoggingCategory>
12 #include <QtCore/QFuture>
13 
14 #include <Quotient/converters.h> // Common for csapi/ headers even though not used here
15 #include <Quotient/quotient_common.h> // For DECL_DEPRECATED_ENUMERATOR
16 
17 class QNetworkRequest;
18 class QNetworkReply;
19 class QSslError;
20 
21 namespace Quotient {
22 class ConnectionData;
23 
24 enum class HttpVerb { Get, Put, Post, Delete };
25 
26 class QUOTIENT_API BaseJob : public QObject {
27  Q_OBJECT
28  Q_PROPERTY(QUrl requestUrl READ requestUrl CONSTANT)
29  Q_PROPERTY(int maxRetries READ maxRetries WRITE setMaxRetries)
30  Q_PROPERTY(int statusCode READ error NOTIFY statusChanged)
31 
32  static QByteArray encodeIfParam(const QString& paramPart);
33  template <int N>
34  static auto encodeIfParam(const char (&literalPart)[N])
35  {
36  return literalPart;
37  }
38 
39 public:
40  //! \brief Job status codes
41  //!
42  //! Every job is created in Unprepared status; upon calling Connection::prepare(), if things are
43  //! fine, it becomes Pending and remains so until the reply arrives; then the status code is
44  //! set according to the job result. At any point in time the job can be abandon()ed, causing
45  //! it to become Abandoned for a brief period before deletion.
46  enum StatusCode {
47  Success = 0,
48  NoError = Success,
49  Pending = 1,
50  WarningLevel = 20, //!< Warnings have codes starting from this
51  UnexpectedResponseType = 21,
52  UnexpectedResponseTypeWarning = UnexpectedResponseType,
53  Unprepared = 25, //!< Initial job state is incomplete, hence warning level
54  Abandoned = 50, //!< A tiny period between abandoning and object deletion
55  ErrorLevel = 100, //!< Errors have codes starting from this
56  NetworkError = 101,
57  Timeout,
58  Unauthorised,
59  ContentAccessError,
60  NotFound,
61  IncorrectRequest,
62  IncorrectResponse,
63  TooManyRequests,
64  RateLimited = TooManyRequests,
65  RequestNotImplemented,
66  UnsupportedRoomVersion,
67  NetworkAuthRequired,
68  UserConsentRequired,
69  CannotLeaveRoom,
70  UserDeactivated,
71  FileError,
72  AccountLocked,
73  UserDefinedError = 256
74  };
75  Q_ENUM(StatusCode)
76 
77  template <typename... StrTs>
78  static QByteArray makePath(QByteArrayView base, StrTs&&... parts)
79  {
80  return (base % ... % encodeIfParam(std::forward<StrTs>(parts)));
81  }
82 
83  //! \brief The status of a job
84  //!
85  //! The status consists of a code that is described (but not delimited) by StatusCode, and
86  //! a freeform message.
87  //!
88  //! To extend the list of error codes, define an (anonymous) enum along the lines of StatusCode,
89  //! with additional values starting at UserDefinedError.
90  struct Status {
91  Status(StatusCode c) : code(c) {}
92  Status(int c, QString m) : code(c), message(std::move(m)) {}
93 
94  static StatusCode fromHttpCode(int httpCode);
95  static Status fromHttpCode(int httpCode, QString msg)
96  {
97  return { fromHttpCode(httpCode), std::move(msg) };
98  }
99 
100  bool good() const { return code < ErrorLevel; }
101  QDebug dumpToLog(QDebug dbg) const;
102  friend QDebug operator<<(const QDebug& dbg, const Status& s)
103  {
104  return s.dumpToLog(dbg);
105  }
106 
107  bool operator==(const Status& other) const
108  {
109  return code == other.code && message == other.message;
110  }
111  bool operator!=(const Status& other) const
112  {
113  return !operator==(other);
114  }
115  bool operator==(int otherCode) const
116  {
117  return code == otherCode;
118  }
119  bool operator!=(int otherCode) const
120  {
121  return !operator==(otherCode);
122  }
123 
124  int code;
125  QString message;
126  };
127 
128 public:
129  BaseJob(HttpVerb verb, const QString& name, QByteArray endpoint,
130  bool needsToken = true);
131  BaseJob(HttpVerb verb, const QString& name, QByteArray endpoint,
132  const QUrlQuery& query, RequestData&& data = {},
133  bool needsToken = true);
134 
135  QUrl requestUrl() const;
136  bool isBackground() const;
137 
138  //! Current status of the job
139  Status status() const;
140 
141  //! Short human-friendly message on the job status
142  QString statusCaption() const;
143 
144  //! \brief Get first bytes of the raw response body as received from the server
145  //! \param bytesAtMost the number of leftmost bytes to return
146  //! \sa rawDataSample
147  QByteArray rawData(int bytesAtMost) const;
148 
149  //! Access the whole response body as received from the server
150  const QByteArray& rawData() const;
151 
152  //! \brief Get UI-friendly sample of raw data
153  //!
154  //! This is almost the same as rawData but appends the "truncated" suffix if not all data fit in
155  //! bytesAtMost. This call is recommended to present a sample of raw data as "details" next to
156  //! error messages. Note that the default \p bytesAtMost value is also tailored to UI cases.
157  //!
158  //! \sa //! rawData
159  QString rawDataSample(int bytesAtMost = 65535) const;
160 
161  //! \brief Get the response body as a JSON object
162  //!
163  //! If the job's returned content type is not `application/json` or if the top-level JSON entity
164  //! is not an object, an empty object is returned.
165  QJsonObject jsonData() const;
166 
167  //! \brief Get the response body as a JSON array
168  //!
169  //! If the job's returned content type is not `application/json` or if the top-level JSON entity
170  //! is not an array, an empty array is returned.
171  QJsonArray jsonItems() const;
172 
173  //! \brief Load the property from the JSON response assuming a given C++ type
174  //!
175  //! If there's no top-level JSON object in the response or if there's
176  //! no node with the key \p keyName, \p defaultValue is returned.
177  template <typename T>
178  T loadFromJson(auto keyName, T&& defaultValue = {}) const
179  {
180  const auto& jv = jsonData().value(keyName);
181  return jv.isUndefined() ? std::forward<T>(defaultValue) : fromJson<T>(jv);
182  }
183 
184  //! \brief Load the property from the JSON response and delete it from JSON
185  //!
186  //! If there's no top-level JSON object in the response or if there's
187  //! no node with the key \p keyName, \p defaultValue is returned.
188  template <typename T>
189  T takeFromJson(auto key, T&& defaultValue = {})
190  {
191  if (const auto& jv = takeValueFromJson(key); !jv.isUndefined())
192  return fromJson<T>(jv);
193 
194  return std::forward<T>(defaultValue);
195  }
196 
197  //! \brief Error (more generally, status) code
198  //!
199  //! Equivalent to status().code
200  //! \sa status, StatusCode
201  int error() const;
202 
203  //! Error-specific message, as returned by the server
204  virtual QString errorString() const;
205 
206  //! A URL to help/clarify the error, if provided by the server
207  QUrl errorUrl() const;
208 
209  int maxRetries() const;
210  void setMaxRetries(int newMaxRetries);
211 
212  using duration_ms_t = std::chrono::milliseconds::rep; // normally int64_t
213 
214  std::chrono::seconds getCurrentTimeout() const;
215  Q_INVOKABLE Quotient::BaseJob::duration_ms_t getCurrentTimeoutMs() const;
216  std::chrono::seconds getNextRetryInterval() const;
217  Q_INVOKABLE Quotient::BaseJob::duration_ms_t getNextRetryMs() const;
218  std::chrono::milliseconds timeToRetry() const;
219  Q_INVOKABLE Quotient::BaseJob::duration_ms_t millisToRetry() const;
220 
221  friend QDebug operator<<(QDebug dbg, const BaseJob* j)
222  {
223  return dbg << j->objectName();
224  }
225 
226 public Q_SLOTS:
227  void initiate(Quotient::ConnectionData* connData, bool inBackground);
228 
229  //! \brief Abandon the result of this job, arrived or unarrived.
230  //!
231  //! This aborts waiting for a reply from the server (if there was
232  //! any pending) and deletes the job object. No result signals
233  //! (result, success, failure) are emitted, only finished() is.
234  void abandon();
235 
236 Q_SIGNALS:
237  //! \brief The job is about to send a network request
238  //!
239  //! This signal is emitted every time a network request is made (which can
240  //! occur several times due to job retries). You can use it to change
241  //! the request parameters (such as redirect policy) if necessary. If you
242  //! need to set additional request headers or query items, do that using
243  //! setRequestHeaders() and setRequestQuery() instead.
244  //! \note \p req is not guaranteed to exist (i.e. it may point to garbage)
245  //! unless this signal is handled via a DirectConnection (or
246  //! BlockingQueuedConnection if in another thread), i.e.,
247  //! synchronously.
248  //! \sa setRequestHeaders, setRequestQuery
249  void aboutToSendRequest(QNetworkRequest* req);
250 
251  //! The job has sent a network request
252  void sentRequest();
253 
254  //! The job has changed its status
255  void statusChanged(Quotient::BaseJob::Status newStatus);
256 
257  //! \brief A retry of the network request is scheduled after the previous request failed
258  //! \param nextAttempt the 1-based number of attempt (will always be more than 1)
259  //! \param inMilliseconds the interval after which the next attempt will be taken
260  void retryScheduled(int nextAttempt, Quotient::BaseJob::duration_ms_t inMilliseconds);
261 
262  //! \brief The job has been rate-limited
263  //!
264  //! The previous network request has been rate-limited; the next attempt will be queued and run
265  //! sometime later. Since other jobs may already wait in the queue, it's not possible to predict
266  //! the wait time.
267  void rateLimited();
268 
269  //! \brief The job has finished - either with a result, or abandoned
270  //!
271  //! Emitted when the job is finished, in any case. It is used to notify
272  //! observers that the job is terminated and that progress can be hidden.
273  //!
274  //! This should not be emitted directly by subclasses; use finishJob() instead.
275  //!
276  //! In general, to be notified of a job's completion, client code should connect to result(),
277  //! success(), or failure() rather than finished(). However if you need to track the job's
278  //! lifecycle you should connect to this instead of result(); in particular, only this signal
279  //! will be emitted on abandoning, the others won't.
280  //!
281  //! \param job the job that emitted this signal
282  //!
283  //! \sa result, success, failure
284  void finished(Quotient::BaseJob* job);
285 
286  //! \brief The job has finished with a result, successful or unsuccessful
287  //!
288  //! Use error() or status().good() to know if the job has finished successfully.
289  //!
290  //! \param job the job that emitted this signal
291  //!
292  //! \sa success, failure
293  void result(Quotient::BaseJob* job);
294 
295  //! \brief The job has finished with a successful result
296  //! \sa result, failure
297  void success(Quotient::BaseJob*);
298 
299  //! \brief The job has finished with a failure result
300  //! Emitted together with result() when the job resulted in an error. Mutually exclusive with
301  //! success(): after result() is emitted, exactly one of success() and failure() will be emitted
302  //! next. Will not be emitted in case of abandon()ing.
303  //!
304  //! \sa result, success
305  void failure(Quotient::BaseJob*);
306 
307  void downloadProgress(qint64 bytesReceived, qint64 bytesTotal);
308  void uploadProgress(qint64 bytesSent, qint64 bytesTotal);
309 
310 protected:
311  using headers_t = QHash<QByteArray, QByteArray>;
312 
313  QByteArray apiEndpoint() const;
314  void setApiEndpoint(QByteArray apiEndpoint);
315  const headers_t& requestHeaders() const;
316  void setRequestHeader(const headers_t::key_type& headerName,
317  const headers_t::mapped_type& headerValue);
318  void setRequestHeaders(const headers_t& headers);
319  QUrlQuery query() const;
320  void setRequestQuery(const QUrlQuery& query);
321  const RequestData& requestData() const;
322  void setRequestData(RequestData&& data);
323  const QByteArrayList& expectedContentTypes() const;
324  void addExpectedContentType(const QByteArray& contentType);
325  void setExpectedContentTypes(const QByteArrayList& contentTypes);
326  QStringList expectedKeys() const;
327  void addExpectedKey(QString key);
328  void setExpectedKeys(const QStringList& keys);
329 
330  const QNetworkReply* reply() const;
331  QNetworkReply* reply();
332 
333  //! \brief Construct a URL out of baseUrl, path and query
334  //!
335  //! The function ensures exactly one '/' between the path component of
336  //! \p baseUrl and \p path. The query component of \p baseUrl is ignored.
337  //! \note Unlike most of BaseJob, this function is thread-safe
338  static QUrl makeRequestUrl(const HomeserverData& hsData, const QByteArray& encodedPath,
339  const QUrlQuery& query = {});
340 
341  //! \brief Prepare the job for execution
342  //!
343  //! This method is called no more than once per job lifecycle, when it's first scheduled
344  //! for execution; in particular, it is not called on retries.
345  virtual void doPrepare(const ConnectionData*);
346 
347  //! \brief Postprocessing after the network request has been sent
348  //!
349  //! This method is called every time the job receives a running
350  //! QNetworkReply object from NetworkAccessManager - basically, after
351  //! successfully sending a network request (including retries).
352  virtual void onSentRequest(QNetworkReply*);
353 
354  virtual void beforeAbandon();
355 
356  //! \brief Check the pending or received reply for upfront issues
357  //!
358  //! This is invoked when headers are first received and also once the complete reply is
359  //! obtained; the base implementation checks the HTTP headers to detect general issues such as
360  //! network errors or access denial and it's strongly recommended to call it from overrides, as
361  //! early as possible.
362  //!
363  //! This slot is const and cannot read the response body from the reply. If you need to read the
364  //! body on the fly, override onSentRequest() and connect in it to reply->readyRead(); and if
365  //! you only need to validate the body after it fully arrived, use prepareResult() for that.
366  //! Returning anything except NoError/Success switches further processing from prepareResult()
367  //! to prepareError().
368  //!
369  //! \return the result of checking the reply
370  //!
371  //! \sa gotReply
372  virtual Status checkReply(const QNetworkReply* reply) const;
373 
374  //! \brief An extension point for additional reply processing
375  //!
376  //! The base implementation simply returns Success without doing anything else.
377  //!
378  //! \sa gotReply
379  virtual Status prepareResult();
380 
381  //! \brief Process details of the error
382  //!
383  //! The function processes the reply in case when status from checkReply() was not good (usually
384  //! because of an unsuccessful HTTP code). The base implementation assumes Matrix JSON error
385  //! object in the body; overrides are strongly recommended to call it for all stock Matrix
386  //! responses as early as possible and only then process custom errors, with JSON or non-JSON
387  //! payload.
388  //!
389  //! \return updated (if necessary) job status
390  virtual Status prepareError(Status currentStatus);
391 
392  //! \brief Retrieve a value for one specific key and delete it from the JSON response object
393  //!
394  //! This allows to implement deserialisation with "move" semantics for parts
395  //! of the response. Assuming that the response body is a valid JSON object,
396  //! the function calls QJsonObject::take(key) on it and returns the result.
397  //!
398  //! \return QJsonValue::Undefined if the response content is not a JSON object or it doesn't
399  //! have \p key; the value for \p key otherwise.
400  //!
401  //! \sa takeFromJson
402  QJsonValue takeValueFromJson(QAnyStringView key);
403 
404  void setStatus(Status s);
405  void setStatus(int code, QString message);
406 
407  //! \brief Force completion of the job for sake of testing
408  //!
409  //! Normal jobs should never use; this is only meant to be used in test mocks.
410  //! \sa Mocked
411  void forceResult(QJsonDocument resultDoc, Status s = { Success });
412 
413  //! \brief Set the logging category for the given job instance
414  //!
415  //! \param lcf The logging category function to provide the category -
416  //! the one you define with Q_LOGGING_CATEGORY (without
417  //! parentheses, BaseJob will call it for you)
418  void setLoggingCategory(QMessageLogger::CategoryFunction lcf);
419 
420  // Job objects should only be deleted via QObject::deleteLater
421  ~BaseJob() override;
422 
423 protected Q_SLOTS:
424  void timeout();
425 
426 private Q_SLOTS:
427  void sendRequest();
428  void gotReply();
429 
430 private:
431  friend class ConnectionData; // to provide access to sendRequest()
432  template <class JobT>
433  friend class JobHandle;
434 
435  void stop();
436  void finishJob();
437  QFuture<void> future();
438 
439  class Private;
440  ImplPtr<Private> d;
441 };
442 
443 inline bool QUOTIENT_API isJobPending(BaseJob* job)
444 {
445  return job && job->error() == BaseJob::Pending;
446 }
447 
448 template <typename JobT>
449 constexpr inline auto doCollectResponse = nullptr;
450 
451 //! \brief Get a job response in a single structure
452 //!
453 //! Use this to get all parts of the job response in a single C++ type, defined by the job class.
454 //! It can be either an aggregate of individual response parts returned by job accessors, or, if
455 //! the response is already singular, a type of this response. The default implementation caters to
456 //! generated jobs, where the code has to be generic enough to work with copyable and movable-only
457 //! responses. For manually written code, simply overload collectResponse() for the respective
458 //! job type, with appropriate constness.
459 template <std::derived_from<BaseJob> JobT>
460 inline auto collectResponse(JobT* job)
461  requires requires { doCollectResponse<JobT>(job); }
462 {
463  return doCollectResponse<JobT>(job);
464 }
465 
466 template <std::derived_from<BaseJob> JobT>
467 class Mocked : public JobT {
468 public:
469  using JobT::JobT;
470  void setResult(QJsonDocument d) { JobT::forceResult(std::move(d)); }
471 };
472 
473 } // namespace Quotient