OSDN Git Service

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