OSDN Git Service

3bd735d2041b34625018ad92a61ff05ce10f6219
[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 async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
177         {
178             var request = new PostMultipartRequest
179             {
180                 RequestUri = uri,
181                 Query = param,
182                 Media = media,
183             };
184
185             using var response = await this.SendAsync(request)
186                 .ConfigureAwait(false);
187
188             return response.ReadAsLazyJson<T>();
189         }
190
191         public async Task PostAsync(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
192         {
193             var request = new PostMultipartRequest
194             {
195                 RequestUri = uri,
196                 Query = param,
197                 Media = media,
198             };
199
200             await this.SendAsync(request)
201                 .IgnoreResponse()
202                 .ConfigureAwait(false);
203         }
204
205         public static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> func, TimeSpan timeout)
206         {
207             using var cts = new CancellationTokenSource();
208             var cancellactionToken = cts.Token;
209
210             var task = Task.Run(() => func(cancellactionToken), cancellactionToken);
211             var timeoutTask = Task.Delay(timeout);
212
213             if (await Task.WhenAny(task, timeoutTask) == timeoutTask)
214             {
215                 // タイムアウト
216
217                 // キャンセル後のタスクで発生した例外は無視する
218                 static async Task IgnoreExceptions(Task task)
219                 {
220                     try
221                     {
222                         await task.ConfigureAwait(false);
223                     }
224                     catch
225                     {
226                     }
227                 }
228                 _ = IgnoreExceptions(task);
229                 cts.Cancel();
230
231                 throw new OperationCanceledException("Timeout", cancellactionToken);
232             }
233
234             return await task;
235         }
236
237         protected static async Task CheckStatusCode(HttpResponseMessage response)
238         {
239             var statusCode = response.StatusCode;
240
241             if ((int)statusCode >= 200 && (int)statusCode <= 299)
242             {
243                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
244                 return;
245             }
246
247             string responseText;
248             using (var content = response.Content)
249             {
250                 responseText = await content.ReadAsStringAsync()
251                     .ConfigureAwait(false);
252             }
253
254             if (string.IsNullOrWhiteSpace(responseText))
255             {
256                 if (statusCode == HttpStatusCode.Unauthorized)
257                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
258
259                 throw new TwitterApiException(statusCode, responseText);
260             }
261
262             try
263             {
264                 var error = TwitterError.ParseJson(responseText);
265
266                 if (error?.Errors == null || error.Errors.Length == 0)
267                     throw new TwitterApiException(statusCode, responseText);
268
269                 var errorCodes = error.Errors.Select(x => x.Code);
270                 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
271                 {
272                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
273                 }
274
275                 throw new TwitterApiException(statusCode, error, responseText);
276             }
277             catch (SerializationException)
278             {
279                 throw new TwitterApiException(statusCode, responseText);
280             }
281         }
282
283         public OAuthEchoHandler CreateOAuthEchoHandler(HttpMessageHandler innerHandler, Uri authServiceProvider, Uri? realm = null)
284         {
285             var uri = new Uri(RestApiBase, authServiceProvider);
286
287             if (this.Credential is TwitterCredentialOAuth1 oauthCredential)
288             {
289                 return OAuthEchoHandler.CreateHandler(
290                     innerHandler,
291                     uri,
292                     oauthCredential.AppToken.OAuth1ConsumerKey,
293                     oauthCredential.AppToken.OAuth1ConsumerSecret,
294                     oauthCredential.Token,
295                     oauthCredential.TokenSecret,
296                     realm);
297             }
298             else
299             {
300                 // MobipictureApi クラス向けの暫定対応
301                 return OAuthEchoHandler.CreateHandler(
302                     innerHandler,
303                     uri,
304                     ApiKey.Create(""),
305                     ApiKey.Create(""),
306                     "",
307                     "",
308                     realm);
309             }
310         }
311
312         public void Dispose()
313         {
314             this.Dispose(true);
315             GC.SuppressFinalize(this);
316         }
317
318         protected virtual void Dispose(bool disposing)
319         {
320             if (this.IsDisposed)
321                 return;
322
323             this.IsDisposed = true;
324
325             if (disposing)
326             {
327                 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
328                 this.Http.Dispose();
329             }
330         }
331
332         ~TwitterApiConnection()
333             => this.Dispose(false);
334
335         private void Networking_WebProxyChanged(object sender, EventArgs e)
336             => this.InitializeHttpClients();
337
338         public static async Task<TwitterCredentialOAuth1> GetRequestTokenAsync(TwitterAppToken appToken)
339         {
340             var emptyCredential = new TwitterCredentialOAuth1(appToken, "", "");
341             var param = new Dictionary<string, string>
342             {
343                 ["oauth_callback"] = "oob",
344             };
345             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, emptyCredential)
346                 .ConfigureAwait(false);
347
348             return new(appToken, response["oauth_token"], response["oauth_token_secret"]);
349         }
350
351         public static Uri GetAuthorizeUri(TwitterCredentialOAuth1 requestToken, string? screenName = null)
352         {
353             var param = new Dictionary<string, string>
354             {
355                 ["oauth_token"] = requestToken.Token,
356             };
357
358             if (screenName != null)
359                 param["screen_name"] = screenName;
360
361             return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
362         }
363
364         public static async Task<IDictionary<string, string>> GetAccessTokenAsync(TwitterCredentialOAuth1 credential, string verifier)
365         {
366             var param = new Dictionary<string, string>
367             {
368                 ["oauth_verifier"] = verifier,
369             };
370             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, credential)
371                 .ConfigureAwait(false);
372
373             return response;
374         }
375
376         private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(
377             Uri uri,
378             IDictionary<string, string> param,
379             TwitterCredentialOAuth1 credential
380         )
381         {
382             using var authorizeClient = InitializeHttpClient(credential);
383
384             var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
385
386             try
387             {
388                 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
389                 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
390                     .ConfigureAwait(false);
391
392                 using var content = response.Content;
393                 var responseText = await content.ReadAsStringAsync()
394                     .ConfigureAwait(false);
395
396                 await TwitterApiConnection.CheckStatusCode(response)
397                     .ConfigureAwait(false);
398
399                 var responseParams = HttpUtility.ParseQueryString(responseText);
400
401                 return responseParams.Cast<string>()
402                     .ToDictionary(x => x, x => responseParams[x]);
403             }
404             catch (HttpRequestException ex)
405             {
406                 throw TwitterApiException.CreateFromException(ex);
407             }
408             catch (OperationCanceledException ex)
409             {
410                 throw TwitterApiException.CreateFromException(ex);
411             }
412         }
413
414         private static HttpClient InitializeHttpClient(ITwitterCredential credential)
415         {
416             var builder = Networking.CreateHttpClientBuilder();
417
418             builder.SetupHttpClientHandler(
419                 x => x.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache)
420             );
421
422             builder.AddHandler(x => credential.CreateHttpHandler(x));
423
424             return builder.Build();
425         }
426     }
427 }