OSDN Git Service

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