OSDN Git Service

OpenTween v2.4.2 リリース
[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.IO;
27 using System.Linq;
28 using System.Net;
29 using System.Net.Cache;
30 using System.Net.Http;
31 using System.Runtime.Serialization;
32 using System.Text;
33 using System.Threading;
34 using System.Threading.Tasks;
35 using System.Web;
36 using OpenTween.Api;
37 using OpenTween.Api.DataModel;
38
39 namespace OpenTween.Connection
40 {
41     public class TwitterApiConnection : IApiConnection, IDisposable
42     {
43         public static Uri RestApiBase { get; set; } = new Uri("https://api.twitter.com/1.1/");
44
45         // SettingCommon.xml の TwitterUrl との互換性のために用意
46         public static string RestApiHost
47         {
48             get => RestApiBase.Host;
49             set => RestApiBase = new Uri($"https://{value}/1.1/");
50         }
51
52         public bool IsDisposed { get; private set; } = false;
53
54         public string AccessToken { get; }
55         public string AccessSecret { get; }
56
57         internal HttpClient http = null!;
58         internal HttpClient httpUpload = null!;
59         internal HttpClient httpStreaming = null!;
60
61         public TwitterApiConnection(string accessToken, string accessSecret)
62         {
63             this.AccessToken = accessToken;
64             this.AccessSecret = accessSecret;
65
66             this.InitializeHttpClients();
67             Networking.WebProxyChanged += this.Networking_WebProxyChanged;
68         }
69
70         private void InitializeHttpClients()
71         {
72             this.http = InitializeHttpClient(this.AccessToken, this.AccessSecret);
73
74             this.httpUpload = InitializeHttpClient(this.AccessToken, this.AccessSecret);
75             this.httpUpload.Timeout = Networking.UploadImageTimeout;
76
77             this.httpStreaming = InitializeHttpClient(this.AccessToken, this.AccessSecret, disableGzip: true);
78             this.httpStreaming.Timeout = Timeout.InfiniteTimeSpan;
79         }
80
81         public async Task<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName)
82         {
83             // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる
84             if (endpointName != null)
85                 this.ThrowIfRateLimitExceeded(endpointName);
86
87             var requestUri = new Uri(RestApiBase, uri);
88
89             if (param != null)
90                 requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param));
91
92             var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
93
94             try
95             {
96                 using var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
97                     .ConfigureAwait(false);
98
99                 if (endpointName != null)
100                     MyCommon.TwitterApiInfo.UpdateFromHeader(response.Headers, endpointName);
101
102                 await this.CheckStatusCode(response)
103                     .ConfigureAwait(false);
104
105                 using var content = response.Content;
106                 var responseText = await content.ReadAsStringAsync()
107                     .ConfigureAwait(false);
108
109                 try
110                 {
111                     return MyCommon.CreateDataFromJson<T>(responseText);
112                 }
113                 catch (SerializationException ex)
114                 {
115                     throw TwitterApiException.CreateFromException(ex, responseText);
116                 }
117             }
118             catch (HttpRequestException ex)
119             {
120                 throw TwitterApiException.CreateFromException(ex);
121             }
122             catch (OperationCanceledException ex)
123             {
124                 throw TwitterApiException.CreateFromException(ex);
125             }
126         }
127
128         /// <summary>
129         /// 指定されたエンドポイントがレートリミット規制中であれば例外を発生させる
130         /// </summary>
131         private void ThrowIfRateLimitExceeded(string endpointName)
132         {
133             var limit = MyCommon.TwitterApiInfo.AccessLimit[endpointName];
134             if (limit == null)
135                 return;
136
137             if (limit.AccessLimitRemain == 0 && limit.AccessLimitResetDate > DateTimeUtc.Now)
138             {
139                 var error = new TwitterError
140                 {
141                     Errors = new[]
142                     {
143                         new TwitterErrorItem { Code = TwitterErrorCode.RateLimit, Message = "" },
144                     },
145                 };
146                 throw new TwitterApiException(0, error, "");
147             }
148         }
149
150         public async Task<Stream> GetStreamAsync(Uri uri, IDictionary<string, string>? param)
151         {
152             var requestUri = new Uri(RestApiBase, uri);
153
154             if (param != null)
155                 requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param));
156
157             try
158             {
159                 return await this.http.GetStreamAsync(requestUri)
160                     .ConfigureAwait(false);
161             }
162             catch (HttpRequestException ex)
163             {
164                 throw TwitterApiException.CreateFromException(ex);
165             }
166             catch (OperationCanceledException ex)
167             {
168                 throw TwitterApiException.CreateFromException(ex);
169             }
170         }
171
172         public async Task<Stream> GetStreamingStreamAsync(Uri uri, IDictionary<string, string>? param)
173         {
174             var requestUri = new Uri(RestApiBase, uri);
175
176             if (param != null)
177                 requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param));
178
179             try
180             {
181                 var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
182                 var response = await this.httpStreaming.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
183                     .ConfigureAwait(false);
184
185                 await this.CheckStatusCode(response)
186                     .ConfigureAwait(false);
187
188                 return await response.Content.ReadAsStreamAsync()
189                     .ConfigureAwait(false);
190             }
191             catch (HttpRequestException ex)
192             {
193                 throw TwitterApiException.CreateFromException(ex);
194             }
195             catch (OperationCanceledException ex)
196             {
197                 throw TwitterApiException.CreateFromException(ex);
198             }
199         }
200
201         public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param)
202         {
203             var requestUri = new Uri(RestApiBase, uri);
204             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
205
206             using var postContent = new FormUrlEncodedContent(param);
207             request.Content = postContent;
208
209             HttpResponseMessage? response = null;
210             try
211             {
212                 response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
213                     .ConfigureAwait(false);
214
215                 await this.CheckStatusCode(response)
216                     .ConfigureAwait(false);
217
218                 var result = new LazyJson<T>(response);
219                 response = null;
220
221                 return result;
222             }
223             catch (HttpRequestException ex)
224             {
225                 throw TwitterApiException.CreateFromException(ex);
226             }
227             catch (OperationCanceledException ex)
228             {
229                 throw TwitterApiException.CreateFromException(ex);
230             }
231             finally
232             {
233                 response?.Dispose();
234             }
235         }
236
237         public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
238         {
239             var requestUri = new Uri(RestApiBase, uri);
240             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
241
242             using var postContent = new MultipartFormDataContent();
243             if (param != null)
244             {
245                 foreach (var (key, value) in param)
246                     postContent.Add(new StringContent(value), key);
247             }
248             if (media != null)
249             {
250                 foreach (var (key, value) in media)
251                     postContent.Add(new StreamContent(value.OpenRead()), key, value.Name);
252             }
253
254             request.Content = postContent;
255
256             HttpResponseMessage? response = null;
257             try
258             {
259                 response = await this.httpUpload.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
260                     .ConfigureAwait(false);
261
262                 await this.CheckStatusCode(response)
263                     .ConfigureAwait(false);
264
265                 var result = new LazyJson<T>(response);
266                 response = null;
267
268                 return result;
269             }
270             catch (HttpRequestException ex)
271             {
272                 throw TwitterApiException.CreateFromException(ex);
273             }
274             catch (OperationCanceledException ex)
275             {
276                 throw TwitterApiException.CreateFromException(ex);
277             }
278             finally
279             {
280                 response?.Dispose();
281             }
282         }
283
284         public async Task PostAsync(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
285         {
286             var requestUri = new Uri(RestApiBase, uri);
287             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
288
289             using var postContent = new MultipartFormDataContent();
290             if (param != null)
291             {
292                 foreach (var (key, value) in param)
293                     postContent.Add(new StringContent(value), key);
294             }
295             if (media != null)
296             {
297                 foreach (var (key, value) in media)
298                     postContent.Add(new StreamContent(value.OpenRead()), key, value.Name);
299             }
300
301             request.Content = postContent;
302
303             try
304             {
305                 using var response = await this.httpUpload.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
306                     .ConfigureAwait(false);
307
308                 await this.CheckStatusCode(response)
309                     .ConfigureAwait(false);
310             }
311             catch (HttpRequestException ex)
312             {
313                 throw TwitterApiException.CreateFromException(ex);
314             }
315             catch (OperationCanceledException ex)
316             {
317                 throw TwitterApiException.CreateFromException(ex);
318             }
319         }
320
321         public async Task PostJsonAsync(Uri uri, string json)
322             => await this.PostJsonAsync<object>(uri, json)
323                          .IgnoreResponse()
324                          .ConfigureAwait(false);
325
326         public async Task<LazyJson<T>> PostJsonAsync<T>(Uri uri, string json)
327         {
328             var requestUri = new Uri(RestApiBase, uri);
329             var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
330
331             using var postContent = new StringContent(json, Encoding.UTF8, "application/json");
332             request.Content = postContent;
333
334             HttpResponseMessage? response = null;
335             try
336             {
337                 response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
338                     .ConfigureAwait(false);
339
340                 await this.CheckStatusCode(response)
341                     .ConfigureAwait(false);
342
343                 var result = new LazyJson<T>(response);
344                 response = null;
345
346                 return result;
347             }
348             catch (HttpRequestException ex)
349             {
350                 throw TwitterApiException.CreateFromException(ex);
351             }
352             catch (OperationCanceledException ex)
353             {
354                 throw TwitterApiException.CreateFromException(ex);
355             }
356             finally
357             {
358                 response?.Dispose();
359             }
360         }
361
362         public async Task DeleteAsync(Uri uri)
363         {
364             var requestUri = new Uri(RestApiBase, uri);
365             using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri);
366
367             try
368             {
369                 using var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
370                     .ConfigureAwait(false);
371
372                 await this.CheckStatusCode(response)
373                     .ConfigureAwait(false);
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         protected async Task CheckStatusCode(HttpResponseMessage response)
386         {
387             var statusCode = response.StatusCode;
388
389             if ((int)statusCode >= 200 && (int)statusCode <= 299)
390             {
391                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
392                 return;
393             }
394
395             string responseText;
396             using (var content = response.Content)
397             {
398                 responseText = await content.ReadAsStringAsync()
399                     .ConfigureAwait(false);
400             }
401
402             if (string.IsNullOrWhiteSpace(responseText))
403             {
404                 if (statusCode == HttpStatusCode.Unauthorized)
405                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
406
407                 throw new TwitterApiException(statusCode, responseText);
408             }
409
410             try
411             {
412                 var error = TwitterError.ParseJson(responseText);
413
414                 if (error?.Errors == null || error.Errors.Length == 0)
415                     throw new TwitterApiException(statusCode, responseText);
416
417                 var errorCodes = error.Errors.Select(x => x.Code);
418                 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
419                 {
420                     Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
421                 }
422
423                 throw new TwitterApiException(statusCode, error, responseText);
424             }
425             catch (SerializationException)
426             {
427                 throw new TwitterApiException(statusCode, responseText);
428             }
429         }
430
431         public OAuthEchoHandler CreateOAuthEchoHandler(Uri authServiceProvider, Uri? realm = null)
432         {
433             var uri = new Uri(RestApiBase, authServiceProvider);
434
435             return OAuthEchoHandler.CreateHandler(Networking.CreateHttpClientHandler(), uri,
436                 ApplicationSettings.TwitterConsumerKey, ApplicationSettings.TwitterConsumerSecret,
437                 this.AccessToken, this.AccessSecret, realm);
438         }
439
440         public void Dispose()
441         {
442             this.Dispose(true);
443             GC.SuppressFinalize(this);
444         }
445
446         protected virtual void Dispose(bool disposing)
447         {
448             if (this.IsDisposed)
449                 return;
450
451             this.IsDisposed = true;
452
453             if (disposing)
454             {
455                 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
456                 this.http.Dispose();
457                 this.httpUpload.Dispose();
458                 this.httpStreaming.Dispose();
459             }
460         }
461
462         ~TwitterApiConnection()
463             => this.Dispose(false);
464
465         private void Networking_WebProxyChanged(object sender, EventArgs e)
466             => this.InitializeHttpClients();
467
468         public static async Task<(string Token, string TokenSecret)> GetRequestTokenAsync()
469         {
470             var param = new Dictionary<string, string>
471             {
472                 ["oauth_callback"] = "oob",
473             };
474             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, oauthToken: null)
475                 .ConfigureAwait(false);
476
477             return (response["oauth_token"], response["oauth_token_secret"]);
478         }
479
480         public static Uri GetAuthorizeUri((string Token, string TokenSecret) requestToken, string? screenName = null)
481         {
482             var param = new Dictionary<string, string>
483             {
484                 ["oauth_token"] = requestToken.Token,
485             };
486
487             if (screenName != null)
488                 param["screen_name"] = screenName;
489
490             return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
491         }
492
493         public static async Task<IDictionary<string, string>> GetAccessTokenAsync((string Token, string TokenSecret) requestToken, string verifier)
494         {
495             var param = new Dictionary<string, string>
496             {
497                 ["oauth_verifier"] = verifier,
498             };
499             var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, requestToken)
500                 .ConfigureAwait(false);
501
502             return response;
503         }
504
505         private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(Uri uri, IDictionary<string, string> param,
506             (string Token, string TokenSecret)? oauthToken)
507         {
508             HttpClient authorizeClient;
509             if (oauthToken != null)
510                 authorizeClient = InitializeHttpClient(oauthToken.Value.Token, oauthToken.Value.TokenSecret);
511             else
512                 authorizeClient = InitializeHttpClient("", "");
513
514             var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
515
516             try
517             {
518                 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
519                 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
520                     .ConfigureAwait(false);
521
522                 using var content = response.Content;
523                 var responseText = await content.ReadAsStringAsync()
524                     .ConfigureAwait(false);
525
526                 if (!response.IsSuccessStatusCode)
527                     throw new TwitterApiException(response.StatusCode, responseText);
528
529                 var responseParams = HttpUtility.ParseQueryString(responseText);
530
531                 return responseParams.Cast<string>()
532                     .ToDictionary(x => x, x => responseParams[x]);
533             }
534             catch (HttpRequestException ex)
535             {
536                 throw TwitterApiException.CreateFromException(ex);
537             }
538             catch (OperationCanceledException ex)
539             {
540                 throw TwitterApiException.CreateFromException(ex);
541             }
542         }
543
544         private static HttpClient InitializeHttpClient(string accessToken, string accessSecret, bool disableGzip = false)
545         {
546             var innerHandler = Networking.CreateHttpClientHandler();
547             innerHandler.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache);
548
549             if (disableGzip)
550                 innerHandler.AutomaticDecompression = DecompressionMethods.None;
551
552             var handler = new OAuthHandler(innerHandler,
553                 ApplicationSettings.TwitterConsumerKey, ApplicationSettings.TwitterConsumerSecret,
554                 accessToken, accessSecret);
555
556             return Networking.CreateHttpClient(handler);
557         }
558     }
559 }