OSDN Git Service

IApiConnection, IHttpRequest, ApiResponseで構成する新しいTwitterApiConnectionを実装
authorKimura Youichi <kim.upsilon@bucyou.net>
Fri, 8 Dec 2023 14:57:30 +0000 (23:57 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Fri, 8 Dec 2023 18:48:38 +0000 (03:48 +0900)
OpenTween.Tests/Connection/ApiResponseTest.cs [new file with mode: 0644]
OpenTween.Tests/Connection/GetRequestTest.cs [new file with mode: 0644]
OpenTween.Tests/Connection/TwitterApiConnectionTest.cs
OpenTween.Tests/MyCommonTest.cs
OpenTween/Connection/ApiResponse.cs [new file with mode: 0644]
OpenTween/Connection/GetRequest.cs [new file with mode: 0644]
OpenTween/Connection/IApiConnection.cs [new file with mode: 0644]
OpenTween/Connection/IApiConnectionLegacy.cs
OpenTween/Connection/IHttpRequest.cs [new file with mode: 0644]
OpenTween/Connection/TwitterApiConnection.cs
OpenTween/MyCommon.cs

diff --git a/OpenTween.Tests/Connection/ApiResponseTest.cs b/OpenTween.Tests/Connection/ApiResponseTest.cs
new file mode 100644 (file)
index 0000000..a1ff5ee
--- /dev/null
@@ -0,0 +1,119 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<TestJson>());
+        }
+
+        [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<TwitterApiException>(
+                () => response.ReadAsJson<TestJson>()
+            );
+            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("""<root type="object"><foo type="number">123</foo></root>""", 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<TwitterApiException>(
+                () => 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 (file)
index 0000000..db738b6
--- /dev/null
@@ -0,0 +1,81 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<string, string>
+                {
+                    ["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<string, string>
+            {
+                ["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<string, string>
+            {
+                ["bbb"] = "222",
+            };
+            Assert.Throws<NotSupportedException>(
+                () => GetRequest.BuildUriWithQuery(uri, query)
+            );
+        }
+    }
+}
index 929f45e..1c7b53c 100644 (file)
@@ -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<string, string>
+                {
+                    ["aaaa"] = "1111",
+                    ["bbbb"] = "2222",
+                },
+                EndpointName = "/hoge/tetete",
+            };
+
+            using var response = await apiConnection.SendAsync(request);
+
+            Assert.Equal("hogehoge", await response.ReadAsJson<string>());
+
+            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<string>(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<TwitterApiException>(
-                () => apiConnection.GetAsync<string>(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<TwitterApiException>(
-                () => apiConnection.GetAsync<string>(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<int> 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<bool>();
+
+            async Task<int> AsyncFunc(CancellationToken token)
+            {
+                await Task.Delay(200);
+                tcs.SetResult(token.IsCancellationRequested);
+                return 1;
+            }
+
+            var timeout = TimeSpan.FromMilliseconds(10);
+            await Assert.ThrowsAsync<OperationCanceledException>(
+                () => TwitterApiConnection.HandleTimeout(AsyncFunc, timeout)
+            );
+
+            var cancelRequested = await tcs.Task;
+            Assert.True(cancelRequested);
+        }
+
+        [Fact]
+        public async Task HandleTimeout_ThrowExceptionAfterTimeoutTest()
+        {
+            var tcs = new TaskCompletionSource<int>();
+
+            async Task<int> AsyncFunc(CancellationToken token)
+            {
+                await Task.Delay(100);
+                tcs.SetResult(1);
+                throw new Exception();
+            }
+
+            var timeout = TimeSpan.FromMilliseconds(10);
+            await Assert.ThrowsAsync<OperationCanceledException>(
+                () => 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;
+        }
     }
 }
index 560c6a8..1261d47 100644 (file)
@@ -135,10 +135,18 @@ namespace OpenTween
 
         [Theory]
         [MemberData(nameof(CreateDataFromJsonTestCase))]
-        public void CreateDataFromJsonTest<T>(string json, T expected)
+        public void CreateDataFromJson_StringTest<T>(string json, T expected)
             => Assert.Equal(expected, MyCommon.CreateDataFromJson<T>(json));
 
         [Theory]
+        [MemberData(nameof(CreateDataFromJsonTestCase))]
+        public void CreateDataFromJson_BytesTest<T>(string json, T expected)
+        {
+            var jsonBytes = Encoding.UTF8.GetBytes(json);
+            Assert.Equal(expected, MyCommon.CreateDataFromJson<T>(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 (file)
index 0000000..3eabcb6
--- /dev/null
@@ -0,0 +1,99 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<byte[]> ReadAsBytes()
+        {
+            using var content = this.responseMessage.Content;
+
+            return await content.ReadAsByteArrayAsync()
+                .ConfigureAwait(false);
+        }
+
+        public async Task<T> ReadAsJson<T>()
+        {
+            var responseBytes = await this.ReadAsBytes()
+                .ConfigureAwait(false);
+
+            try
+            {
+                return MyCommon.CreateDataFromJson<T>(responseBytes);
+            }
+            catch (SerializationException ex)
+            {
+                var responseText = Encoding.UTF8.GetString(responseBytes);
+                throw TwitterApiException.CreateFromException(ex, responseText);
+            }
+        }
+
+        public async Task<XElement> 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 (file)
index 0000000..018354a
--- /dev/null
@@ -0,0 +1,56 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<string, string>? 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<KeyValuePair<string, string>>? 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 (file)
index 0000000..16e8166
--- /dev/null
@@ -0,0 +1,33 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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<ApiResponse> SendAsync(IHttpRequest request);
+    }
+}
index 9e22318..4175e58 100644 (file)
@@ -30,7 +30,7 @@ using System.Threading.Tasks;
 
 namespace OpenTween.Connection
 {
-    public interface IApiConnectionLegacy : IDisposable
+    public interface IApiConnectionLegacy : IApiConnection, IDisposable
     {
         Task<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName);
 
diff --git a/OpenTween/Connection/IHttpRequest.cs b/OpenTween/Connection/IHttpRequest.cs
new file mode 100644 (file)
index 0000000..55dd9e3
--- /dev/null
@@ -0,0 +1,35 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, 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);
+    }
+}
index 338c835..091a320 100644 (file)
@@ -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<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName)
+        public async Task<ApiResponse> 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<T>(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<T> GetAsync<T>(Uri uri, IDictionary<string, string>? 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<T>()
+                .ConfigureAwait(false);
         }
 
         /// <summary>
@@ -442,6 +454,38 @@ namespace OpenTween.Connection
             }
         }
 
+        public static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> 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;
index 5e51960..e6b1f3d 100644 (file)
@@ -719,9 +719,11 @@ namespace OpenTween
         }
 
         public static T CreateDataFromJson<T>(string content)
+            => MyCommon.CreateDataFromJson<T>(Encoding.UTF8.GetBytes(content));
+
+        public static T CreateDataFromJson<T>(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,