From 143acec4822a0b8b6d55e763da2252b4c70f154e Mon Sep 17 00:00:00 2001 From: Kimura Youichi Date: Fri, 8 Dec 2023 23:57:30 +0900 Subject: [PATCH] =?utf8?q?IApiConnection,=20IHttpRequest,=20ApiResponse?= =?utf8?q?=E3=81=A7=E6=A7=8B=E6=88=90=E3=81=99=E3=82=8B=E6=96=B0=E3=81=97?= =?utf8?q?=E3=81=84TwitterApiConnection=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- OpenTween.Tests/Connection/ApiResponseTest.cs | 119 ++++++++++++++++ OpenTween.Tests/Connection/GetRequestTest.cs | 81 +++++++++++ .../Connection/TwitterApiConnectionTest.cs | 151 +++++++++++++++++++-- OpenTween.Tests/MyCommonTest.cs | 10 +- OpenTween/Connection/ApiResponse.cs | 99 ++++++++++++++ OpenTween/Connection/GetRequest.cs | 56 ++++++++ OpenTween/Connection/IApiConnection.cs | 33 +++++ OpenTween/Connection/IApiConnectionLegacy.cs | 2 +- OpenTween/Connection/IHttpRequest.cs | 35 +++++ OpenTween/Connection/TwitterApiConnection.cs | 90 ++++++++---- OpenTween/MyCommon.cs | 6 +- 11 files changed, 642 insertions(+), 40 deletions(-) create mode 100644 OpenTween.Tests/Connection/ApiResponseTest.cs create mode 100644 OpenTween.Tests/Connection/GetRequestTest.cs create mode 100644 OpenTween/Connection/ApiResponse.cs create mode 100644 OpenTween/Connection/GetRequest.cs create mode 100644 OpenTween/Connection/IApiConnection.cs create mode 100644 OpenTween/Connection/IHttpRequest.cs diff --git a/OpenTween.Tests/Connection/ApiResponseTest.cs b/OpenTween.Tests/Connection/ApiResponseTest.cs new file mode 100644 index 00000000..a1ff5eee --- /dev/null +++ b/OpenTween.Tests/Connection/ApiResponseTest.cs @@ -0,0 +1,119 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System.Net; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using System.Xml.Linq; +using OpenTween.Api; +using Xunit; + +namespace OpenTween.Connection +{ + public class ApiResponseTest + { + [Fact] + public async Task ReadAsBytes_Test() + { + using var responseContent = new ByteArrayContent(new byte[] { 1, 2, 3 }); + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = responseContent, + }; + using var response = new ApiResponse(responseMessage); + + Assert.Equal(new byte[] { 1, 2, 3 }, await response.ReadAsBytes()); + } + + [DataContract] + public struct TestJson + { + [DataMember(Name = "foo")] + public int Foo { get; set; } + } + + [Fact] + public async Task ReadAsJson_Test() + { + using var responseContent = new StringContent("""{"foo":123}"""); + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = responseContent, + }; + using var response = new ApiResponse(responseMessage); + + Assert.Equal(new() { Foo = 123 }, await response.ReadAsJson()); + } + + [Fact] + public async Task ReadAsJson_InvalidJsonTest() + { + using var responseContent = new StringContent("### Invalid JSON Response ###"); + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = responseContent, + }; + using var response = new ApiResponse(responseMessage); + + var ex = await Assert.ThrowsAsync( + () => response.ReadAsJson() + ); + Assert.Equal("### Invalid JSON Response ###", ex.ResponseText); + } + + [Fact] + public async Task ReadAsJsonXml_Test() + { + using var responseContent = new StringContent("""{"foo":123}"""); + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = responseContent, + }; + using var response = new ApiResponse(responseMessage); + + var rootElm = await response.ReadAsJsonXml(); + var xmlString = rootElm.ToString(SaveOptions.DisableFormatting); + Assert.Equal("""123""", xmlString); + } + + [Fact] + public async Task ReadAsJsonXml_InvalidJsonTest() + { + using var responseContent = new StringContent("### Invalid JSON Response ###"); + using var responseMessage = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = responseContent, + }; + using var response = new ApiResponse(responseMessage); + + var ex = await Assert.ThrowsAsync( + () => response.ReadAsJsonXml() + ); + Assert.Equal("### Invalid JSON Response ###", ex.ResponseText); + } + } +} diff --git a/OpenTween.Tests/Connection/GetRequestTest.cs b/OpenTween.Tests/Connection/GetRequestTest.cs new file mode 100644 index 00000000..db738b66 --- /dev/null +++ b/OpenTween.Tests/Connection/GetRequestTest.cs @@ -0,0 +1,81 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace OpenTween.Connection +{ + public class GetRequestTest + { + [Fact] + public void CreateMessage_Test() + { + var request = new GetRequest + { + RequestUri = new("statuses/show.json", UriKind.Relative), + Query = new Dictionary + { + ["id"] = "12345", + }, + }; + + var baseUri = new Uri("https://api.twitter.com/v1/"); + using var requestMessage = request.CreateMessage(baseUri); + + Assert.Equal(HttpMethod.Get, requestMessage.Method); + Assert.Equal(new("https://api.twitter.com/v1/statuses/show.json?id=12345"), requestMessage.RequestUri); + } + + [Fact] + public void BuildUriWithQuery_Test() + { + var uri = new Uri("https://example.com/hoge"); + var query = new Dictionary + { + ["foo"] = "bar", + }; + Assert.Equal(new("https://example.com/hoge?foo=bar"), GetRequest.BuildUriWithQuery(uri, query)); + } + + [Fact] + public void BuildUriWithQuery_NullTest() + { + var uri = new Uri("https://example.com/hoge"); + Assert.Equal(new("https://example.com/hoge"), GetRequest.BuildUriWithQuery(uri, null)); + } + + [Fact] + public void BuildUriWithQuery_CannotMergeTest() + { + var uri = new Uri("https://example.com/hoge?aaa=111"); + var query = new Dictionary + { + ["bbb"] = "222", + }; + Assert.Throws( + () => GetRequest.BuildUriWithQuery(uri, query) + ); + } + } +} diff --git a/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs b/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs index 929f45eb..1c7b53cd 100644 --- a/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs +++ b/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs @@ -29,6 +29,7 @@ using System.Net.Http.Headers; using System.Reflection; using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Web; using Moq; @@ -127,7 +128,50 @@ namespace OpenTween.Connection } [Fact] - public async Task GetAsync_UpdateRateLimitTest() + public async Task SendAsync_Test() + { + using var mockHandler = new HttpMessageHandlerMock(); + using var http = new HttpClient(mockHandler); + using var apiConnection = new TwitterApiConnection(ApiKey.Create(""), ApiKey.Create(""), "", ""); + apiConnection.Http = http; + + mockHandler.Enqueue(x => + { + Assert.Equal(HttpMethod.Get, x.Method); + Assert.Equal("https://api.twitter.com/1.1/hoge/tetete.json", + x.RequestUri.GetLeftPart(UriPartial.Path)); + + var query = HttpUtility.ParseQueryString(x.RequestUri.Query); + + Assert.Equal("1111", query["aaaa"]); + Assert.Equal("2222", query["bbbb"]); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("\"hogehoge\""), + }; + }); + + var request = new GetRequest + { + RequestUri = new("hoge/tetete.json", UriKind.Relative), + Query = new Dictionary + { + ["aaaa"] = "1111", + ["bbbb"] = "2222", + }, + EndpointName = "/hoge/tetete", + }; + + using var response = await apiConnection.SendAsync(request); + + Assert.Equal("hogehoge", await response.ReadAsJson()); + + Assert.Equal(0, mockHandler.QueueCount); + } + + [Fact] + public async Task SendAsync_UpdateRateLimitTest() { using var mockHandler = new HttpMessageHandlerMock(); using var http = new HttpClient(mockHandler); @@ -144,10 +188,10 @@ namespace OpenTween.Connection { Headers = { - { "X-Rate-Limit-Limit", "150" }, - { "X-Rate-Limit-Remaining", "100" }, - { "X-Rate-Limit-Reset", "1356998400" }, - { "X-Access-Level", "read-write-directmessages" }, + { "X-Rate-Limit-Limit", "150" }, + { "X-Rate-Limit-Remaining", "100" }, + { "X-Rate-Limit-Reset", "1356998400" }, + { "X-Access-Level", "read-write-directmessages" }, }, Content = new StringContent("\"hogehoge\""), }; @@ -156,9 +200,13 @@ namespace OpenTween.Connection var apiStatus = new TwitterApiStatus(); MyCommon.TwitterApiInfo = apiStatus; - var endpoint = new Uri("hoge/tetete.json", UriKind.Relative); + var request = new GetRequest + { + RequestUri = new("hoge/tetete.json", UriKind.Relative), + EndpointName = "/hoge/tetete", + }; - await apiConnection.GetAsync(endpoint, null, endpointName: "/hoge/tetete"); + using var response = await apiConnection.SendAsync(request); Assert.Equal(TwitterApiAccessLevel.ReadWriteAndDirectMessage, apiStatus.AccessLevel); Assert.Equal(new ApiLimit(150, 100, new DateTimeUtc(2013, 1, 1, 0, 0, 0)), apiStatus.AccessLimit["/hoge/tetete"]); @@ -167,7 +215,7 @@ namespace OpenTween.Connection } [Fact] - public async Task GetAsync_ErrorStatusTest() + public async Task SendAsync_ErrorStatusTest() { using var mockHandler = new HttpMessageHandlerMock(); using var http = new HttpClient(mockHandler); @@ -182,10 +230,13 @@ namespace OpenTween.Connection }; }); - var endpoint = new Uri("hoge/tetete.json", UriKind.Relative); + var request = new GetRequest + { + RequestUri = new("hoge/tetete.json", UriKind.Relative), + }; var exception = await Assert.ThrowsAsync( - () => apiConnection.GetAsync(endpoint, null, endpointName: "/hoge/tetete") + () => apiConnection.SendAsync(request) ); // エラーレスポンスの読み込みに失敗した場合はステータスコードをそのままメッセージに使用する @@ -196,7 +247,7 @@ namespace OpenTween.Connection } [Fact] - public async Task GetAsync_ErrorJsonTest() + public async Task SendAsync_ErrorJsonTest() { using var mockHandler = new HttpMessageHandlerMock(); using var http = new HttpClient(mockHandler); @@ -211,10 +262,13 @@ namespace OpenTween.Connection }; }); - var endpoint = new Uri("hoge/tetete.json", UriKind.Relative); + var request = new GetRequest + { + RequestUri = new("hoge/tetete.json", UriKind.Relative), + }; var exception = await Assert.ThrowsAsync( - () => apiConnection.GetAsync(endpoint, null, endpointName: "/hoge/tetete") + () => apiConnection.SendAsync(request) ); // エラーレスポンスの JSON に含まれるエラーコードに基づいてメッセージを出力する @@ -519,5 +573,76 @@ namespace OpenTween.Connection Assert.Equal(0, mockHandler.QueueCount); } + + [Fact] + public async Task HandleTimeout_SuccessTest() + { + static async Task AsyncFunc(CancellationToken token) + { + await Task.Delay(10); + token.ThrowIfCancellationRequested(); + return 1; + } + + var timeout = TimeSpan.FromMilliseconds(200); + var ret = await TwitterApiConnection.HandleTimeout(AsyncFunc, timeout); + + Assert.Equal(1, ret); + } + + [Fact] + public async Task HandleTimeout_TimeoutTest() + { + var tcs = new TaskCompletionSource(); + + async Task AsyncFunc(CancellationToken token) + { + await Task.Delay(200); + tcs.SetResult(token.IsCancellationRequested); + return 1; + } + + var timeout = TimeSpan.FromMilliseconds(10); + await Assert.ThrowsAsync( + () => TwitterApiConnection.HandleTimeout(AsyncFunc, timeout) + ); + + var cancelRequested = await tcs.Task; + Assert.True(cancelRequested); + } + + [Fact] + public async Task HandleTimeout_ThrowExceptionAfterTimeoutTest() + { + var tcs = new TaskCompletionSource(); + + async Task AsyncFunc(CancellationToken token) + { + await Task.Delay(100); + tcs.SetResult(1); + throw new Exception(); + } + + var timeout = TimeSpan.FromMilliseconds(10); + await Assert.ThrowsAsync( + () => TwitterApiConnection.HandleTimeout(AsyncFunc, timeout) + ); + + // キャンセル後に AsyncFunc で発生した例外が無視される(UnobservedTaskException イベントを発生させない)ことをチェックする + var error = false; + void UnobservedExceptionHandler(object s, UnobservedTaskExceptionEventArgs e) + => error = true; + + TaskScheduler.UnobservedTaskException += UnobservedExceptionHandler; + + await tcs.Task; + await Task.Delay(10); + GC.Collect(); // UnobservedTaskException は Task のデストラクタで呼ばれるため強制的に GC を実行する + await Task.Delay(10); + + Assert.False(error); + + TaskScheduler.UnobservedTaskException -= UnobservedExceptionHandler; + } } } diff --git a/OpenTween.Tests/MyCommonTest.cs b/OpenTween.Tests/MyCommonTest.cs index 560c6a82..1261d473 100644 --- a/OpenTween.Tests/MyCommonTest.cs +++ b/OpenTween.Tests/MyCommonTest.cs @@ -135,10 +135,18 @@ namespace OpenTween [Theory] [MemberData(nameof(CreateDataFromJsonTestCase))] - public void CreateDataFromJsonTest(string json, T expected) + public void CreateDataFromJson_StringTest(string json, T expected) => Assert.Equal(expected, MyCommon.CreateDataFromJson(json)); [Theory] + [MemberData(nameof(CreateDataFromJsonTestCase))] + public void CreateDataFromJson_BytesTest(string json, T expected) + { + var jsonBytes = Encoding.UTF8.GetBytes(json); + Assert.Equal(expected, MyCommon.CreateDataFromJson(jsonBytes)); + } + + [Theory] [InlineData("hoge123@example.com", true)] [InlineData("hogehoge", false)] [InlineData("foo.bar@example.com", true)] diff --git a/OpenTween/Connection/ApiResponse.cs b/OpenTween/Connection/ApiResponse.cs new file mode 100644 index 00000000..3eabcb66 --- /dev/null +++ b/OpenTween/Connection/ApiResponse.cs @@ -0,0 +1,99 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using OpenTween.Api; + +namespace OpenTween.Connection +{ + public sealed class ApiResponse : IDisposable + { + public bool IsDisposed { get; private set; } + + private readonly HttpResponseMessage responseMessage; + + public ApiResponse(HttpResponseMessage responseMessage) + => this.responseMessage = responseMessage; + + public void Dispose() + { + if (this.IsDisposed) + return; + + this.responseMessage.Dispose(); + this.IsDisposed = true; + } + + public async Task ReadAsBytes() + { + using var content = this.responseMessage.Content; + + return await content.ReadAsByteArrayAsync() + .ConfigureAwait(false); + } + + public async Task ReadAsJson() + { + var responseBytes = await this.ReadAsBytes() + .ConfigureAwait(false); + + try + { + return MyCommon.CreateDataFromJson(responseBytes); + } + catch (SerializationException ex) + { + var responseText = Encoding.UTF8.GetString(responseBytes); + throw TwitterApiException.CreateFromException(ex, responseText); + } + } + + public async Task ReadAsJsonXml() + { + var responseBytes = await this.ReadAsBytes() + .ConfigureAwait(false); + + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader( + responseBytes, + XmlDictionaryReaderQuotas.Max + ); + + try + { + return XElement.Load(jsonReader); + } + catch (XmlException ex) + { + var responseText = Encoding.UTF8.GetString(responseBytes); + throw new TwitterApiException("Invalid JSON", ex) { ResponseText = responseText }; + } + } + } +} diff --git a/OpenTween/Connection/GetRequest.cs b/OpenTween/Connection/GetRequest.cs new file mode 100644 index 00000000..018354a1 --- /dev/null +++ b/OpenTween/Connection/GetRequest.cs @@ -0,0 +1,56 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace OpenTween.Connection +{ + public class GetRequest : IHttpRequest + { + public required Uri RequestUri { get; set; } + + public IDictionary? Query { get; set; } + + public string? EndpointName { get; set; } + + public HttpRequestMessage CreateMessage(Uri baseUri) + => new() + { + Method = HttpMethod.Get, + RequestUri = BuildUriWithQuery(new(baseUri, this.RequestUri), this.Query), + }; + + public static Uri BuildUriWithQuery(Uri uri, IEnumerable>? query) + { + if (query == null) + return uri; + + if (!MyCommon.IsNullOrEmpty(uri.Query)) + throw new NotSupportedException("Merging uri query is not supported"); + + return new Uri(uri, "?" + MyCommon.BuildQueryString(query)); + } + } +} diff --git a/OpenTween/Connection/IApiConnection.cs b/OpenTween/Connection/IApiConnection.cs new file mode 100644 index 00000000..16e81666 --- /dev/null +++ b/OpenTween/Connection/IApiConnection.cs @@ -0,0 +1,33 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Threading.Tasks; + +namespace OpenTween.Connection +{ + public interface IApiConnection : IDisposable + { + Task SendAsync(IHttpRequest request); + } +} diff --git a/OpenTween/Connection/IApiConnectionLegacy.cs b/OpenTween/Connection/IApiConnectionLegacy.cs index 9e223184..4175e589 100644 --- a/OpenTween/Connection/IApiConnectionLegacy.cs +++ b/OpenTween/Connection/IApiConnectionLegacy.cs @@ -30,7 +30,7 @@ using System.Threading.Tasks; namespace OpenTween.Connection { - public interface IApiConnectionLegacy : IDisposable + public interface IApiConnectionLegacy : IApiConnection, IDisposable { Task GetAsync(Uri uri, IDictionary? param, string? endpointName); diff --git a/OpenTween/Connection/IHttpRequest.cs b/OpenTween/Connection/IHttpRequest.cs new file mode 100644 index 00000000..55dd9e3b --- /dev/null +++ b/OpenTween/Connection/IHttpRequest.cs @@ -0,0 +1,35 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Net.Http; + +namespace OpenTween.Connection +{ + public interface IHttpRequest + { + string? EndpointName { get; } + + HttpRequestMessage CreateMessage(Uri baseUri); + } +} diff --git a/OpenTween/Connection/TwitterApiConnection.cs b/OpenTween/Connection/TwitterApiConnection.cs index 338c8358..091a3203 100644 --- a/OpenTween/Connection/TwitterApiConnection.cs +++ b/OpenTween/Connection/TwitterApiConnection.cs @@ -39,7 +39,7 @@ using OpenTween.Api.DataModel; namespace OpenTween.Connection { - public class TwitterApiConnection : IApiConnectionLegacy, IDisposable + public class TwitterApiConnection : IApiConnection, IApiConnectionLegacy, IDisposable { public static Uri RestApiBase { get; set; } = new("https://api.twitter.com/1.1/"); @@ -98,42 +98,34 @@ namespace OpenTween.Connection this.HttpStreaming.Timeout = Timeout.InfiniteTimeSpan; } - public async Task GetAsync(Uri uri, IDictionary? param, string? endpointName) + public async Task SendAsync(IHttpRequest request) { + var endpointName = request.EndpointName; + // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる if (endpointName != null) this.ThrowIfRateLimitExceeded(endpointName); - var requestUri = new Uri(RestApiBase, uri); - - if (param != null) - requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param)); - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + using var requestMessage = request.CreateMessage(RestApiBase); + HttpResponseMessage? responseMessage = null; try { - using var response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) - .ConfigureAwait(false); + responseMessage = await HandleTimeout( + (token) => this.Http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, token), + Networking.DefaultTimeout + ); if (endpointName != null) - MyCommon.TwitterApiInfo.UpdateFromHeader(response.Headers, endpointName); + MyCommon.TwitterApiInfo.UpdateFromHeader(responseMessage.Headers, endpointName); - await TwitterApiConnection.CheckStatusCode(response) + await TwitterApiConnection.CheckStatusCode(responseMessage) .ConfigureAwait(false); - using var content = response.Content; - var responseText = await content.ReadAsStringAsync() - .ConfigureAwait(false); + var response = new ApiResponse(responseMessage); + responseMessage = null; // responseMessage は ApiResponse で使用するため破棄されないようにする - try - { - return MyCommon.CreateDataFromJson(responseText); - } - catch (SerializationException ex) - { - throw TwitterApiException.CreateFromException(ex, responseText); - } + return response; } catch (HttpRequestException ex) { @@ -143,6 +135,26 @@ namespace OpenTween.Connection { throw TwitterApiException.CreateFromException(ex); } + finally + { + responseMessage?.Dispose(); + } + } + + public async Task GetAsync(Uri uri, IDictionary? param, string? endpointName) + { + var request = new GetRequest + { + RequestUri = uri, + Query = param, + EndpointName = endpointName, + }; + + using var response = await this.SendAsync(request) + .ConfigureAwait(false); + + return await response.ReadAsJson() + .ConfigureAwait(false); } /// @@ -442,6 +454,38 @@ namespace OpenTween.Connection } } + public static async Task HandleTimeout(Func> func, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(); + var cancellactionToken = cts.Token; + + var task = Task.Run(() => func(cancellactionToken), cancellactionToken); + var timeoutTask = Task.Delay(timeout); + + if (await Task.WhenAny(task, timeoutTask) == timeoutTask) + { + // タイムアウト + + // キャンセル後のタスクで発生した例外は無視する + static async Task IgnoreExceptions(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + _ = IgnoreExceptions(task); + cts.Cancel(); + + throw new OperationCanceledException("Timeout", cancellactionToken); + } + + return await task; + } + protected static async Task CheckStatusCode(HttpResponseMessage response) { var statusCode = response.StatusCode; diff --git a/OpenTween/MyCommon.cs b/OpenTween/MyCommon.cs index 5e51960f..e6b1f3d7 100644 --- a/OpenTween/MyCommon.cs +++ b/OpenTween/MyCommon.cs @@ -719,9 +719,11 @@ namespace OpenTween } public static T CreateDataFromJson(string content) + => MyCommon.CreateDataFromJson(Encoding.UTF8.GetBytes(content)); + + public static T CreateDataFromJson(byte[] bytes) { - var buf = Encoding.Unicode.GetBytes(content); - using var stream = new MemoryStream(buf); + using var stream = new MemoryStream(bytes); var settings = new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true, -- 2.11.0