diff --git a/src/Services/Network/Network.vala b/src/Services/Network/Network.vala index 786cb3fda..84029eabd 100644 --- a/src/Services/Network/Network.vala +++ b/src/Services/Network/Network.vala @@ -1,5 +1,4 @@ public class Tuba.Network : GLib.Object { - public signal void started (); public signal void finished (); @@ -106,6 +105,35 @@ public class Tuba.Network : GLib.Object { }); } + public async GLib.InputStream queue_v2 ( + owned Soup.Message msg, + GLib.Cancellable? cancellable, + out Soup.MessageHeaders response_headers + ) throws GLib.Error, Oopsie { + requests_processing++; + + GLib.InputStream in_stream = yield session.send_async (msg, 0, cancellable); + var status = msg.status_code; + response_headers = msg.response_headers; + + if (status >= 200 && status < 300) + return in_stream; + + unowned string error_msg = msg.reason_phrase; + try { + var parser = yield Network.get_parser_from_inputstream_async (in_stream); + var root = network.parse (parser); + if (root != null) { + error_msg = root.has_member ("message") + ? root.get_string_member_with_default ("message", msg.reason_phrase) + : root.get_string_member_with_default ("error", msg.reason_phrase); + } + } catch {} + + critical (@"Request \"$(msg.uri.to_string ())\" failed: $status $(msg.reason_phrase) $error_msg"); + throw new Oopsie.INSTANCE (error_msg); + } + public void on_error (int32 code, string message) { warning (message); app.toast (message, 0); @@ -128,6 +156,12 @@ public class Tuba.Network : GLib.Object { return parser; } + public static async Json.Parser get_parser_from_inputstream_async (InputStream in_stream) throws Error { + var parser = new Json.Parser (); + yield parser.load_from_stream_async (in_stream); + return parser; + } + public static Json.Array? get_array_mstd (Json.Parser parser) { return parser.get_root ().get_array (); } diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala new file mode 100644 index 000000000..2b0ffcade --- /dev/null +++ b/src/Services/Network/RequestV2.vala @@ -0,0 +1,159 @@ +public class Tuba.RequestV2 : GLib.Object { + public enum Method { + GET, + POST, + PUT, + DELETE, + PATCH; + + public string to_string () { + switch (this) { + case GET: return "GET"; + case POST: return "POST"; + case PUT: return "PUT"; + case DELETE: return "DELETE"; + case PATCH: return "PATCH"; + default: assert_not_reached (); + } + } + } + + public Method method { get; private set; default = GET; } + public string url { get; private set; } + public Soup.MessagePriority priority { get; set; default = NORMAL; } + public GLib.Cancellable? cancellable { get; set; default = null; } // priv? + public InstanceAccount? account { get; set; default = null; } + public string? force_token { get; set; default = null; } + public bool no_auth { get; set; default = false; } + public bool cache { get; set; default = true; } + public weak Gtk.Widget? ctx { + set { + this._ctx = value; + if (this._ctx != null) { + this._ctx.destroy.connect (on_ctx_destroy); + } + } + } + + private GLib.HashTable parameters = new GLib.HashTable (str_hash, str_equal); + private string? content_type { get; set; default = null; } + private weak Gtk.Widget? _ctx = null; + private Soup.Multipart? form_data = null; + private Bytes? body_bytes = null; + + private void on_ctx_destroy () { + this.cancellable.cancel (); + this.ctx = null; + } + + public RequestV2 (string url, Method method = GET) { + this.method = method; + this.url = url; + } + + public void add_parameter (string key, string value) { + parameters.insert ( + GLib.Uri.escape_string (key, "[]"), + GLib.Uri.escape_string (value) + ); + } + + public bool remove_parameter (string key) { + string final_key = GLib.Uri.escape_string (key, "[]"); + if (!this.parameters.contains (final_key)) return false; + + return parameters.foreach_remove ((p_key, p_val) => { + return p_key == final_key || p_key == @"$final_key[]"; + }) > 0; + } + + public void add_parameter_array (string key, string[] values) { + if (values.length == 0) return; + + string final_key = key; + if (!final_key.has_suffix ("[]")) final_key = @"$key[]"; + foreach (unowned string value in values) { + add_parameter (final_key, value); + } + } + + public void add_form_data (string name, string val) { + if (this.form_data == null) + this.form_data = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); + this.form_data.append_form_string (name, val); + } + + public void add_form_data_file (string name, string mime, Bytes buffer) { + if (this.form_data == null) + this.form_data = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); + this.form_data.append_form_file (name, mime.replace ("/", "."), mime, buffer); + } + + public void set_body (string? content_type, Bytes? bytes) { + this.content_type = content_type; + body_bytes = bytes; + } + + public void set_body_from_json (Json.Builder json_builder) { + Json.Generator generator = new Json.Generator (); + generator.set_root (json_builder.get_root ()); + set_body ("application/json", new Bytes.take (generator.to_data (null).data)); + } + + public async GLib.InputStream exec (out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { + if (this.cancellable != null) this.cancellable.cancel (); + this.cancellable = new GLib.Cancellable (); + + string final_url = build_final_url (); + Soup.Message message; + if (this.form_data == null) { + GLib.Uri final_uri = GLib.Uri.parse (final_url, UriFlags.ENCODED_PATH | UriFlags.ENCODED_QUERY); + message = new Soup.Message.from_uri (this.method.to_string (), final_uri); + } else { + message = new Soup.Message.from_multipart (final_url, this.form_data); + // POST is default for multipart + if (this.method != POST) message.method = this.method.to_string (); + } + + if (!no_auth) { + if (force_token != null) { + message.request_headers.append ("Authorization", @"Bearer $force_token"); + } else if (account != null && account.access_token != null) { + message.request_headers.append ("Authorization", @"Bearer $(account.access_token)"); + } + } else { + message.request_headers.remove ("Authorization"); + } + + if (!cache) message.disable_feature (typeof (Soup.Cache)); + message.priority = priority; + + if (this.content_type != null && this.body_bytes != null) + message.set_request_body_from_bytes (this.content_type, this.body_bytes); + + return yield network.queue_v2 (message, this.cancellable, out response_headers); + // TODO: ensure body_bytes = ctx = null... + } + + private string build_final_url () { + string final_url = this.account != null && this.url.has_prefix ("/") + ? @"$(this.account.instance)$(this.url)" + : this.url; + final_url += @"$("?" in this.url ? "&" : "?")$(parameters_to_string ())"; + return final_url; + } + + private string parameters_to_string () { + string res = ""; + if (this.parameters.length == 0) return res; + + int i = 0; + this.parameters.foreach ((key, val) => { + i++; + res += @"$key=$val"; // already escaped + if (i < this.parameters.length) res += "&"; + }); + + return (owned) res; + } +} diff --git a/src/Services/Network/meson.build b/src/Services/Network/meson.build index 55161e68e..60fbe20de 100644 --- a/src/Services/Network/meson.build +++ b/src/Services/Network/meson.build @@ -1,6 +1,7 @@ sources += files( 'Network.vala', 'Request.vala', + 'RequestV2.vala', 'Streamable.vala', 'Streams.vala', ) diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index 46c911927..7d503fb46 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -212,33 +212,46 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase } public virtual bool request () { - append_params (new Request.GET (get_req_url ())) - .with_account (account) - .with_ctx (this) - .with_extra_data (Tuba.Network.ExtraData.RESPONSE_HEADERS) - .then ((in_stream, headers) => { - var parser = Network.get_parser_from_inputstream (in_stream); - - Object[] to_add = {}; - Network.parse_array (parser, node => { - var e = Tuba.Helper.Entity.from_json (node, accepts); - if (!(should_hide (e))) to_add += e; - }); - model.splice (model.get_n_items (), 0, to_add); - - if (headers != null) - get_pages (headers.get_one ("Link")); - - if (to_add.length == 0) - on_content_changed (); - on_request_finish (); - }) - .on_error (on_error) - .exec (); + var req = new RequestV2 (get_req_url ()) { + account = account, + ctx = this + }; + + if (page_next == null) + req.add_parameter ("limit", settings.timeline_page_size.clamp (this.batch_size_min, 40).to_string ()); + request_async.begin (req); return GLib.Source.REMOVE; } + private async void request_async (RequestV2 req) { + GLib.InputStream in_stream; + Soup.MessageHeaders response_headers; + + try { + in_stream = yield req.exec (out response_headers); + Json.Parser parser = yield Network.get_parser_from_inputstream_async (in_stream); + + Object[] to_add = {}; + Network.parse_array (parser, node => { + var e = Helper.Entity.from_json (node, accepts); + if (!(should_hide (e))) to_add += e; + }); + model.splice (model.get_n_items (), 0, to_add); + + if (response_headers != null) + get_pages (response_headers.get_one ("Link")); + + if (to_add.length == 0) + on_content_changed (); + on_request_finish (); + } catch (GLib.IOError.CANCELLED e) { + debug ("Message is cancelled."); + } catch (GLib.Error e) { + on_error (e.code, e.message); + } + } + public override void on_error (int32 code, string reason) { if (base_status == null) { warning (@"Error while refreshing $label: $code $reason");