OSDN Git Service

2b3f651bce254a74c8a0cee19a31f61e88f181cb
[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         internal HttpClient HttpUpload;
57         internal HttpClient HttpStreaming;
58
59         internal ITwitterCredential Credential { get; }
60
61         public TwitterApiConnection()
62             : this(new TwitterCredentialNone())
63         {
64         }
65
66         public TwitterApiConnection(ITwitterCredential credential)
67         {
68             this.Credential = credential;
69
70             this.InitializeHttpClients();
71             Networking.WebProxyChanged += this.Networking_WebProxyChanged;
72         }
73
74         [MemberNotNull(nameof(Http), nameof(HttpUpload), nameof(HttpStreaming))]
75         private void InitializeHttpClients()
76         {
77             this.Http = InitializeHttpClient(this.Credential);
78
79             this.HttpUpload = InitializeHttpClient(this.Credential);
80             this.HttpUpload.Timeout = Networking.UploadImageTimeout;
81
82             this.HttpStreaming = InitializeHttpClient(this.Credential, disableGzip: true);
83             this.HttpStreaming.Timeout = Timeout.InfiniteTimeSpan;
84         }
85
86         public async Task<ApiResponse> SendAsync(IHttpRequest request)
87         {
88             var endpointName = request.EndpointName;
89
90             // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる
91             if (endpointName != null)
92                 this.ThrowIfRateLimitExceeded(endpointName);
93
94             using var requestMessage = request.CreateMessage(RestApiBase);
95
96             HttpResponseMessage? responseMessage = null;
97             try
98             {
99                 responseMessage = await HandleTimeout(
100                     (token) => this.Http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, token),
101                     Networking.DefaultTimeout
102                 );
103
104                 if (endpointName != null)
105                     MyCommon.TwitterApiInfo.UpdateFromHeader(responseMessage.Headers, endpointName);
106
107                 await TwitterApiConnection.CheckStatusCode(responseMessage)
108                     .ConfigureAwait(false);
109
110                 var response = new ApiResponse(responseMessage);
111                 responseMessage = null; // responseMessage は ApiResponse で使用するため破棄されないようにする
112
113                 return response;
114             }
115             catch (HttpRequestException ex)
116             {
117                 throw TwitterApiException.CreateFromException(ex);
118             }
119             catch (OperationCanceledException ex)
120             {
121                 throw TwitterApiException.CreateFromException(ex);
122             }
123             finally
124             {
125                 responseMessage?.Dispose();
126             }
127         }
128
129         public async Task<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName)
130         {
131             var request = new GetRequest
132             {
133                 RequestUri = uri,
134                 Query = param,
135                 EndpointName = endpointName,
136             };
137
138             using var response = await this.SendAsync(request)
139                 .ConfigureAwait(false);
140
141             return await response.ReadAsJson<T>()
142                 .ConfigureAwait(false);
143         }
144
145         /// <summary>
146         /// 指定されたエンドポイントがレートリミット規制中であれば例外を発生させる
147         /// </summary>
148         private void ThrowIfRateLimitExceeded(string endpointName)
149         {
150             var limit = MyCommon.TwitterApiInfo.AccessLimit[endpointName];
151             if (limit == null)
152                 return;
153
154             if (limit.AccessLimitRemain == 0 && limit.AccessLimitResetDate > DateTimeUtc.Now)
155             {
156                 var error = new TwitterError
157                 {
158                     Errors = new[]
159                     {
160                         new TwitterErrorItem { Code = TwitterErrorCode.RateLimit, Message = "" },
161                     },
162                 };
163                 throw new TwitterApiException(0, error, "");
164             }
165         }
166
167         public Task<Stream> GetStreamAsync(Uri uri, IDictionary<string, string>? param)
168             => this.GetStreamAsync(uri, param, null);
169
170         public async Task<Stream> GetStreamAsync(Uri uri, IDictionary<string, string>? param, string? endpointName)
171         {
172             // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる
173             if (endpointName != null)
174                 this.ThrowIfRateLimitExceeded(endpointName);
175
176             var requestUri = new Uri(RestApiBase, uri);
177
178             if (param != null)
179                 requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param));
180
181             try
182             {
183                 var response = await this.Http.GetAsync(requestUri)
184                     .ConfigureAwait(false);
185
186                 if (endpointName != null)
187                     MyCommon.TwitterApiInfo.UpdateFromHeader(response.Headers, endpointName);
188
189                 await TwitterApiConnection.CheckStatusCode(response)
190                     .ConfigureAwait(false);
191
192                 return await response.Content.ReadAsStreamAsync()
193                     .ConfigureAwait(false);
194             }
195             catch (HttpRequestException ex)
196             {
197                 throw TwitterApiException.CreateFromException(ex);
198             }
199             catch (OperationCanceledException ex)
200             {
201                 throw TwitterApiException.CreateFromException(ex);
202             }
203         }
204
205         public async Task<Stream> GetStreamingStreamAsync(Uri uri, IDictionary<string, string>? param)
206         {
207             var requestUri = new Uri(RestApiBase, uri);
208
209             if (param != null)
210                 requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param));
211
212             try
213             {
214                 var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
215                 var response = await this.HttpStreaming.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
216                     .ConfigureAwait(false);
217
218                 await TwitterApiConnection.CheckStatusCode(response)
219                     .ConfigureAwait(false);
220
221                 return await response.Content.ReadAsStreamAsync()
222                     .ConfigureAwait(false);
223             }
224             catch (HttpRequestException ex)
225             {
226                 throw TwitterApiException.CreateFromException(ex);
227             }
228             catch (OperationCanceledException ex)
229             {
230                 throw TwitterApiException.CreateFromException(ex);
231             }
232         }
233
234         public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param)
235         {
236             var requestUri = new Uri(RestApiBase, uri);
237             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
238
239             using var postContent = new FormUrlEncodedContent(param);
240             request.Content = postContent;
241
242             HttpResponseMessage? response = null;
243             try
244             {
245                 response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
246                     .ConfigureAwait(false);
247
248                 await TwitterApiConnection.CheckStatusCode(response)
249                     .ConfigureAwait(false);
250
251                 var result = new LazyJson<T>(response);
252                 response = null;
253
254                 return result;
255             }
256             catch (HttpRequestException ex)
257             {
258                 throw TwitterApiException.CreateFromException(ex);
259             }
260             catch (OperationCanceledException ex)
261             {
262                 throw TwitterApiException.CreateFromException(ex);
263             }
264             finally
265             {
266                 response?.Dispose();
267             }
268         }
269
270         public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
271         {
272             var requestUri = new Uri(RestApiBase, uri);
273             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
274
275             using var postContent = new MultipartFormDataContent();
276             if (param != null)
277             {
278                 foreach (var (key, value) in param)
279                     postContent.Add(new StringContent(value), key);
280             }
281             if (media != null)
282             {
283                 foreach (var (key, value) in media)
284                     postContent.Add(new StreamContent(value.OpenRead()), key, value.Name);
285             }
286
287             request.Content = postContent;
288
289             HttpResponseMessage? response = null;
290             try
291             {
292                 response = await this.HttpUpload.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
293                     .ConfigureAwait(false);
294
295                 await TwitterApiConnection.CheckStatusCode(response)
296                     .ConfigureAwait(false);
297
298                 var result = new LazyJson<T>(response);
299                 response = null;
300
301                 return result;
302             }
303             catch (HttpRequestException ex)
304             {
305                 throw TwitterApiException.CreateFromException(ex);
306             }
307             catch (OperationCanceledException ex)
308             {
309                 throw TwitterApiException.CreateFromException(ex);
310             }
311             finally
312             {
313                 response?.Dispose();
314             }
315         }
316
317         public async Task PostAsync(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
318         {
319             var requestUri = new Uri(RestApiBase, uri);
320             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
321
322             using var postContent = new MultipartFormDataContent();
323             if (param != null)
324             {
325                 foreach (var (key, value) in param)
326                     postContent.Add(new StringContent(value), key);
327             }
328             if (media != null)
329             {
330                 foreach (var (key, value) in media)
331                     postContent.Add(new StreamContent(value.OpenRead()), key, value.Name);
332             }
333
334             request.Content = postContent;
335
336             try
337             {
338                 using var response = await this.HttpUpload.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
339                     .ConfigureAwait(false);
340
341                 await TwitterApiConnection.CheckStatusCode(response)
342                     .ConfigureAwait(false);
343             }
344             catch (HttpRequestException ex)
345             {
346                 throw TwitterApiException.CreateFromException(ex);
347             }
348             catch (OperationCanceledException ex)
349             {
350                 throw TwitterApiException.CreateFromException(ex);
351             }
352         }
353
354         public async Task DeleteAsync(Uri uri)
355         {
356             var requestUri = new Uri(RestApiBase, uri);
357             using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
358
359             try
360             {
361                 using var response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
362                     .ConfigureAwait(false);
363
364                 await TwitterApiConnection.CheckStatusCode(response)
365                     .ConfigureAwait(false);
366             }
367             catch (HttpRequestException ex)
368             {
369                 throw TwitterApiException.CreateFromException(ex);
370             }
371             catch (OperationCanceledException ex)
372             {
373                 throw TwitterApiException.CreateFromException(ex);
374             }
375         }
376
377         public static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> func, TimeSpan timeout)
378         {
379             using var cts = new CancellationTokenSource();
380             var cancellactionToken = cts.Token;
381
382             var task = Task.Run(() => func(cancellactionToken), cancellactionToken);
383             var timeoutTask = Task.Delay(timeout);
384
385             if (await Task.WhenAny(task, timeoutTask) == timeoutTask)
386             {
387                 // タイムアウト
388
389                 // キャンセル後のタスクで発生した例外は無視する
390                 static async Task IgnoreExceptions(Task task)
391                 {
392                     try
393                     {
394                         await task.ConfigureAwait(false);
395                     }
396                     catch
397                     {
398                     }
399                 }
400                 _ = IgnoreExceptions(task);
401                 cts.Cancel();
402
403                 throw new OperationCanceledException("Timeout", cancellactionToken);
404             }
405
406             return await task;
407         }
408
409         protected static async Task CheckStatusCode(HttpResponseMessage response)
410         {
411             var statusCode = response.StatusCode;
412
413             if ((int)statusCode >= 200 && (int)statusCode <= 299)
414             {
415                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
416                 return;
417             }
418
419             string responseText;
420             using (var content = response.Content)
421             {
422                 responseText = await content.ReadAsStringAsync()
423                     .ConfigureAwait(false);
424             }
425
426             if (string.IsNullOrWhiteSpace(responseText))
427             {
428                 if (statusCode == HttpStatusCode.Unauthorized)
429                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
430
431                 throw new TwitterApiException(statusCode, responseText);
432             }
433
434             try
435             {
436                 var error = TwitterError.ParseJson(responseText);
437
438                 if (error?.Errors == null || error.Errors.Length == 0)
439                     throw new TwitterApiException(statusCode, responseText);
440
441                 var errorCodes = error.Errors.Select(x => x.Code);
442                 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
443                 {
444                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
445                 }
446
447                 throw new TwitterApiException(statusCode, error, responseText);
448             }
449             catch (SerializationException)
450             {
451                 throw new TwitterApiException(statusCode, responseText);
452             }
453         }
454
455         public OAuthEchoHandler CreateOAuthEchoHandler(HttpMessageHandler innerHandler, Uri authServiceProvider, Uri? realm = null)
456         {
457             var uri = new Uri(RestApiBase, authServiceProvider);
458
459             if (this.Credential is TwitterCredentialOAuth1 oauthCredential)
460             {
461                 return OAuthEchoHandler.CreateHandler(
462                     innerHandler,
463                     uri,
464                     oauthCredential.AppToken.OAuth1ConsumerKey,
465                     oauthCredential.AppToken.OAuth1ConsumerSecret,
466                     oauthCredential.Token,
467                     oauthCredential.TokenSecret,
468                     realm);
469             }
470             else
471             {
472                 // MobipictureApi クラス向けの暫定対応
473                 return OAuthEchoHandler.CreateHandler(
474                     innerHandler,
475                     uri,
476                     ApiKey.Create(""),
477                     ApiKey.Create(""),
478                     "",
479                     "",
480                     realm);
481             }
482         }
483
484         public void Dispose()
485         {
486             this.Dispose(true);
487             GC.SuppressFinalize(this);
488         }
489
490         protected virtual void Dispose(bool disposing)
491         {
492             if (this.IsDisposed)
493                 return;
494
495             this.IsDisposed = true;
496
497             if (disposing)
498             {
499                 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
500                 this.Http.Dispose();
501                 this.HttpUpload.Dispose();
502                 this.HttpStreaming.Dispose();
503             }
504         }
505
506         ~TwitterApiConnection()
507             => this.Dispose(false);
508
509         private void Networking_WebProxyChanged(object sender, EventArgs e)
510             => this.InitializeHttpClients();
511
512         public static async Task<TwitterCredentialOAuth1> GetRequestTokenAsync(TwitterAppToken appToken)
513         {
514             var emptyCredential = new TwitterCredentialOAuth1(appToken, "", "");
515             var param = new Dictionary<string, string>
516             {
517                 ["oauth_callback"] = "oob",
518             };
519             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, emptyCredential)
520                 .ConfigureAwait(false);
521
522             return new(appToken, response["oauth_token"], response["oauth_token_secret"]);
523         }
524
525         public static Uri GetAuthorizeUri(TwitterCredentialOAuth1 requestToken, string? screenName = null)
526         {
527             var param = new Dictionary<string, string>
528             {
529                 ["oauth_token"] = requestToken.Token,
530             };
531
532             if (screenName != null)
533                 param["screen_name"] = screenName;
534
535             return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
536         }
537
538         public static async Task<IDictionary<string, string>> GetAccessTokenAsync(TwitterCredentialOAuth1 credential, string verifier)
539         {
540             var param = new Dictionary<string, string>
541             {
542                 ["oauth_verifier"] = verifier,
543             };
544             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, credential)
545                 .ConfigureAwait(false);
546
547             return response;
548         }
549
550         private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(
551             Uri uri,
552             IDictionary<string, string> param,
553             TwitterCredentialOAuth1 credential
554         )
555         {
556             using var authorizeClient = InitializeHttpClient(credential);
557
558             var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
559
560             try
561             {
562                 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
563                 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
564                     .ConfigureAwait(false);
565
566                 using var content = response.Content;
567                 var responseText = await content.ReadAsStringAsync()
568                     .ConfigureAwait(false);
569
570                 await TwitterApiConnection.CheckStatusCode(response)
571                     .ConfigureAwait(false);
572
573                 var responseParams = HttpUtility.ParseQueryString(responseText);
574
575                 return responseParams.Cast<string>()
576                     .ToDictionary(x => x, x => responseParams[x]);
577             }
578             catch (HttpRequestException ex)
579             {
580                 throw TwitterApiException.CreateFromException(ex);
581             }
582             catch (OperationCanceledException ex)
583             {
584                 throw TwitterApiException.CreateFromException(ex);
585             }
586         }
587
588         private static HttpClient InitializeHttpClient(ITwitterCredential credential, bool disableGzip = false)
589         {
590             var builder = Networking.CreateHttpClientBuilder();
591
592             builder.SetupHttpClientHandler(x =>
593             {
594                 x.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache);
595
596                 if (disableGzip)
597                     x.AutomaticDecompression = DecompressionMethods.None;
598             });
599
600             builder.AddHandler(x => credential.CreateHttpHandler(x));
601
602             return builder.Build();
603         }
604     }
605 }