OSDN Git Service

IMediaItemを引数に取るTwitterApiConnection.PostLazyAsyncメソッドを削除
[opentween/open-tween.git] / OpenTween / Connection / TwitterApiConnection.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2016 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
3 // All rights reserved.
4 //
5 // This file is part of OpenTween.
6 //
7 // This program is free software; you can redistribute it and/or modify it
8 // under the terms of the GNU General Public License as published by the Free
9 // Software Foundation; either version 3 of the License, or (at your option)
10 // any later version.
11 //
12 // This program is distributed in the hope that it will be useful, but
13 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
14 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
15 // for more details.
16 //
17 // You should have received a copy of the GNU General Public License along
18 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
19 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
20 // Boston, MA 02110-1301, USA.
21
22 #nullable enable
23
24 using System;
25 using System.Collections.Generic;
26 using System.Diagnostics.CodeAnalysis;
27 using System.IO;
28 using System.Linq;
29 using System.Net;
30 using System.Net.Cache;
31 using System.Net.Http;
32 using System.Runtime.Serialization;
33 using System.Text;
34 using System.Threading;
35 using System.Threading.Tasks;
36 using System.Web;
37 using OpenTween.Api;
38 using OpenTween.Api.DataModel;
39
40 namespace OpenTween.Connection
41 {
42     public class TwitterApiConnection : IApiConnection, IApiConnectionLegacy, IDisposable
43     {
44         public static Uri RestApiBase { get; set; } = new("https://api.twitter.com/1.1/");
45
46         // SettingCommon.xml の TwitterUrl との互換性のために用意
47         public static string RestApiHost
48         {
49             get => RestApiBase.Host;
50             set => RestApiBase = new Uri($"https://{value}/1.1/");
51         }
52
53         public bool IsDisposed { get; private set; } = false;
54
55         internal HttpClient Http;
56
57         internal ITwitterCredential Credential { get; }
58
59         public TwitterApiConnection()
60             : this(new TwitterCredentialNone())
61         {
62         }
63
64         public TwitterApiConnection(ITwitterCredential credential)
65         {
66             this.Credential = credential;
67
68             this.InitializeHttpClients();
69             Networking.WebProxyChanged += this.Networking_WebProxyChanged;
70         }
71
72         [MemberNotNull(nameof(Http))]
73         private void InitializeHttpClients()
74         {
75             this.Http = InitializeHttpClient(this.Credential);
76
77             // タイムアウト設定は IHttpRequest.Timeout でリクエスト毎に制御する
78             this.Http.Timeout = Timeout.InfiniteTimeSpan;
79         }
80
81         public async Task<ApiResponse> SendAsync(IHttpRequest request)
82         {
83             var endpointName = request.EndpointName;
84
85             // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる
86             if (endpointName != null)
87                 this.ThrowIfRateLimitExceeded(endpointName);
88
89             using var requestMessage = request.CreateMessage(RestApiBase);
90
91             HttpResponseMessage? responseMessage = null;
92             try
93             {
94                 responseMessage = await HandleTimeout(
95                     (token) => this.Http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, token),
96                     request.Timeout
97                 );
98
99                 if (endpointName != null)
100                     MyCommon.TwitterApiInfo.UpdateFromHeader(responseMessage.Headers, endpointName);
101
102                 await TwitterApiConnection.CheckStatusCode(responseMessage)
103                     .ConfigureAwait(false);
104
105                 var response = new ApiResponse(responseMessage);
106                 responseMessage = null; // responseMessage は ApiResponse で使用するため破棄されないようにする
107
108                 return response;
109             }
110             catch (HttpRequestException ex)
111             {
112                 throw TwitterApiException.CreateFromException(ex);
113             }
114             catch (OperationCanceledException ex)
115             {
116                 throw TwitterApiException.CreateFromException(ex);
117             }
118             finally
119             {
120                 responseMessage?.Dispose();
121             }
122         }
123
124         public async Task<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName)
125         {
126             var request = new GetRequest
127             {
128                 RequestUri = uri,
129                 Query = param,
130                 EndpointName = endpointName,
131             };
132
133             using var response = await this.SendAsync(request)
134                 .ConfigureAwait(false);
135
136             return await response.ReadAsJson<T>()
137                 .ConfigureAwait(false);
138         }
139
140         /// <summary>
141         /// 指定されたエンドポイントがレートリミット規制中であれば例外を発生させる
142         /// </summary>
143         private void ThrowIfRateLimitExceeded(string endpointName)
144         {
145             var limit = MyCommon.TwitterApiInfo.AccessLimit[endpointName];
146             if (limit == null)
147                 return;
148
149             if (limit.AccessLimitRemain == 0 && limit.AccessLimitResetDate > DateTimeUtc.Now)
150             {
151                 var error = new TwitterError
152                 {
153                     Errors = new[]
154                     {
155                         new TwitterErrorItem { Code = TwitterErrorCode.RateLimit, Message = "" },
156                     },
157                 };
158                 throw new TwitterApiException(0, error, "");
159             }
160         }
161
162         public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param)
163         {
164             var request = new PostRequest
165             {
166                 RequestUri = uri,
167                 Query = param,
168             };
169
170             using var response = await this.SendAsync(request)
171                 .ConfigureAwait(false);
172
173             return response.ReadAsLazyJson<T>();
174         }
175
176         public static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> func, TimeSpan timeout)
177         {
178             using var cts = new CancellationTokenSource();
179             var cancellactionToken = cts.Token;
180
181             var task = Task.Run(() => func(cancellactionToken), cancellactionToken);
182             var timeoutTask = Task.Delay(timeout);
183
184             if (await Task.WhenAny(task, timeoutTask) == timeoutTask)
185             {
186                 // タイムアウト
187
188                 // キャンセル後のタスクで発生した例外は無視する
189                 static async Task IgnoreExceptions(Task task)
190                 {
191                     try
192                     {
193                         await task.ConfigureAwait(false);
194                     }
195                     catch
196                     {
197                     }
198                 }
199                 _ = IgnoreExceptions(task);
200                 cts.Cancel();
201
202                 throw new OperationCanceledException("Timeout", cancellactionToken);
203             }
204
205             return await task;
206         }
207
208         protected static async Task CheckStatusCode(HttpResponseMessage response)
209         {
210             var statusCode = response.StatusCode;
211
212             if ((int)statusCode >= 200 && (int)statusCode <= 299)
213             {
214                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
215                 return;
216             }
217
218             string responseText;
219             using (var content = response.Content)
220             {
221                 responseText = await content.ReadAsStringAsync()
222                     .ConfigureAwait(false);
223             }
224
225             if (string.IsNullOrWhiteSpace(responseText))
226             {
227                 if (statusCode == HttpStatusCode.Unauthorized)
228                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
229
230                 throw new TwitterApiException(statusCode, responseText);
231             }
232
233             try
234             {
235                 var error = TwitterError.ParseJson(responseText);
236
237                 if (error?.Errors == null || error.Errors.Length == 0)
238                     throw new TwitterApiException(statusCode, responseText);
239
240                 var errorCodes = error.Errors.Select(x => x.Code);
241                 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
242                 {
243                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
244                 }
245
246                 throw new TwitterApiException(statusCode, error, responseText);
247             }
248             catch (SerializationException)
249             {
250                 throw new TwitterApiException(statusCode, responseText);
251             }
252         }
253
254         public OAuthEchoHandler CreateOAuthEchoHandler(HttpMessageHandler innerHandler, Uri authServiceProvider, Uri? realm = null)
255         {
256             var uri = new Uri(RestApiBase, authServiceProvider);
257
258             if (this.Credential is TwitterCredentialOAuth1 oauthCredential)
259             {
260                 return OAuthEchoHandler.CreateHandler(
261                     innerHandler,
262                     uri,
263                     oauthCredential.AppToken.OAuth1ConsumerKey,
264                     oauthCredential.AppToken.OAuth1ConsumerSecret,
265                     oauthCredential.Token,
266                     oauthCredential.TokenSecret,
267                     realm);
268             }
269             else
270             {
271                 // MobipictureApi クラス向けの暫定対応
272                 return OAuthEchoHandler.CreateHandler(
273                     innerHandler,
274                     uri,
275                     ApiKey.Create(""),
276                     ApiKey.Create(""),
277                     "",
278                     "",
279                     realm);
280             }
281         }
282
283         public void Dispose()
284         {
285             this.Dispose(true);
286             GC.SuppressFinalize(this);
287         }
288
289         protected virtual void Dispose(bool disposing)
290         {
291             if (this.IsDisposed)
292                 return;
293
294             this.IsDisposed = true;
295
296             if (disposing)
297             {
298                 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
299                 this.Http.Dispose();
300             }
301         }
302
303         ~TwitterApiConnection()
304             => this.Dispose(false);
305
306         private void Networking_WebProxyChanged(object sender, EventArgs e)
307             => this.InitializeHttpClients();
308
309         public static async Task<TwitterCredentialOAuth1> GetRequestTokenAsync(TwitterAppToken appToken)
310         {
311             var emptyCredential = new TwitterCredentialOAuth1(appToken, "", "");
312             var param = new Dictionary<string, string>
313             {
314                 ["oauth_callback"] = "oob",
315             };
316             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, emptyCredential)
317                 .ConfigureAwait(false);
318
319             return new(appToken, response["oauth_token"], response["oauth_token_secret"]);
320         }
321
322         public static Uri GetAuthorizeUri(TwitterCredentialOAuth1 requestToken, string? screenName = null)
323         {
324             var param = new Dictionary<string, string>
325             {
326                 ["oauth_token"] = requestToken.Token,
327             };
328
329             if (screenName != null)
330                 param["screen_name"] = screenName;
331
332             return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
333         }
334
335         public static async Task<IDictionary<string, string>> GetAccessTokenAsync(TwitterCredentialOAuth1 credential, string verifier)
336         {
337             var param = new Dictionary<string, string>
338             {
339                 ["oauth_verifier"] = verifier,
340             };
341             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, credential)
342                 .ConfigureAwait(false);
343
344             return response;
345         }
346
347         private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(
348             Uri uri,
349             IDictionary<string, string> param,
350             TwitterCredentialOAuth1 credential
351         )
352         {
353             using var authorizeClient = InitializeHttpClient(credential);
354
355             var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
356
357             try
358             {
359                 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
360                 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
361                     .ConfigureAwait(false);
362
363                 using var content = response.Content;
364                 var responseText = await content.ReadAsStringAsync()
365                     .ConfigureAwait(false);
366
367                 await TwitterApiConnection.CheckStatusCode(response)
368                     .ConfigureAwait(false);
369
370                 var responseParams = HttpUtility.ParseQueryString(responseText);
371
372                 return responseParams.Cast<string>()
373                     .ToDictionary(x => x, x => responseParams[x]);
374             }
375             catch (HttpRequestException ex)
376             {
377                 throw TwitterApiException.CreateFromException(ex);
378             }
379             catch (OperationCanceledException ex)
380             {
381                 throw TwitterApiException.CreateFromException(ex);
382             }
383         }
384
385         private static HttpClient InitializeHttpClient(ITwitterCredential credential)
386         {
387             var builder = Networking.CreateHttpClientBuilder();
388
389             builder.SetupHttpClientHandler(
390                 x => x.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache)
391             );
392
393             builder.AddHandler(x => credential.CreateHttpHandler(x));
394
395             return builder.Build();
396         }
397     }
398 }