1 // OpenTween - Client of Twitter
2 // Copyright (c) 2016 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
3 // All rights reserved.
5 // This file is part of OpenTween.
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)
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
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.
25 using System.Collections.Generic;
26 using System.Diagnostics.CodeAnalysis;
30 using System.Net.Cache;
31 using System.Net.Http;
32 using System.Runtime.Serialization;
34 using System.Threading;
35 using System.Threading.Tasks;
38 using OpenTween.Api.DataModel;
40 namespace OpenTween.Connection
42 public class TwitterApiConnection : IApiConnection, IApiConnectionLegacy, IDisposable
44 public static Uri RestApiBase { get; set; } = new("https://api.twitter.com/1.1/");
46 // SettingCommon.xml の TwitterUrl との互換性のために用意
47 public static string RestApiHost
49 get => RestApiBase.Host;
50 set => RestApiBase = new Uri($"https://{value}/1.1/");
53 public bool IsDisposed { get; private set; } = false;
55 internal HttpClient Http;
57 internal ITwitterCredential Credential { get; }
59 public TwitterApiConnection()
60 : this(new TwitterCredentialNone())
64 public TwitterApiConnection(ITwitterCredential credential)
66 this.Credential = credential;
68 this.InitializeHttpClients();
69 Networking.WebProxyChanged += this.Networking_WebProxyChanged;
72 [MemberNotNull(nameof(Http))]
73 private void InitializeHttpClients()
75 this.Http = InitializeHttpClient(this.Credential);
77 // タイムアウト設定は IHttpRequest.Timeout でリクエスト毎に制御する
78 this.Http.Timeout = Timeout.InfiniteTimeSpan;
81 public async Task<ApiResponse> SendAsync(IHttpRequest request)
83 var endpointName = request.EndpointName;
85 // レートリミット規制中はAPIリクエストを送信せずに直ちにエラーを発生させる
86 if (endpointName != null)
87 this.ThrowIfRateLimitExceeded(endpointName);
89 using var requestMessage = request.CreateMessage(RestApiBase);
91 HttpResponseMessage? responseMessage = null;
94 responseMessage = await HandleTimeout(
95 (token) => this.Http.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, token),
99 if (endpointName != null)
100 MyCommon.TwitterApiInfo.UpdateFromHeader(responseMessage.Headers, endpointName);
102 await TwitterApiConnection.CheckStatusCode(responseMessage)
103 .ConfigureAwait(false);
105 var response = new ApiResponse(responseMessage);
106 responseMessage = null; // responseMessage は ApiResponse で使用するため破棄されないようにする
110 catch (HttpRequestException ex)
112 throw TwitterApiException.CreateFromException(ex);
114 catch (OperationCanceledException ex)
116 throw TwitterApiException.CreateFromException(ex);
120 responseMessage?.Dispose();
124 public async Task<T> GetAsync<T>(Uri uri, IDictionary<string, string>? param, string? endpointName)
126 var request = new GetRequest
130 EndpointName = endpointName,
133 using var response = await this.SendAsync(request)
134 .ConfigureAwait(false);
136 return await response.ReadAsJson<T>()
137 .ConfigureAwait(false);
141 /// 指定されたエンドポイントがレートリミット規制中であれば例外を発生させる
143 private void ThrowIfRateLimitExceeded(string endpointName)
145 var limit = MyCommon.TwitterApiInfo.AccessLimit[endpointName];
149 if (limit.AccessLimitRemain == 0 && limit.AccessLimitResetDate > DateTimeUtc.Now)
151 var error = new TwitterError
155 new TwitterErrorItem { Code = TwitterErrorCode.RateLimit, Message = "" },
158 throw new TwitterApiException(0, error, "");
162 public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param)
164 var request = new PostRequest
170 using var response = await this.SendAsync(request)
171 .ConfigureAwait(false);
173 return response.ReadAsLazyJson<T>();
176 public async Task<LazyJson<T>> PostLazyAsync<T>(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
178 var request = new PostMultipartRequest
185 using var response = await this.SendAsync(request)
186 .ConfigureAwait(false);
188 return response.ReadAsLazyJson<T>();
191 public async Task PostAsync(Uri uri, IDictionary<string, string>? param, IDictionary<string, IMediaItem>? media)
193 var request = new PostMultipartRequest
200 await this.SendAsync(request)
202 .ConfigureAwait(false);
205 public static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> func, TimeSpan timeout)
207 using var cts = new CancellationTokenSource();
208 var cancellactionToken = cts.Token;
210 var task = Task.Run(() => func(cancellactionToken), cancellactionToken);
211 var timeoutTask = Task.Delay(timeout);
213 if (await Task.WhenAny(task, timeoutTask) == timeoutTask)
217 // キャンセル後のタスクで発生した例外は無視する
218 static async Task IgnoreExceptions(Task task)
222 await task.ConfigureAwait(false);
228 _ = IgnoreExceptions(task);
231 throw new OperationCanceledException("Timeout", cancellactionToken);
237 protected static async Task CheckStatusCode(HttpResponseMessage response)
239 var statusCode = response.StatusCode;
241 if ((int)statusCode >= 200 && (int)statusCode <= 299)
243 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
248 using (var content = response.Content)
250 responseText = await content.ReadAsStringAsync()
251 .ConfigureAwait(false);
254 if (string.IsNullOrWhiteSpace(responseText))
256 if (statusCode == HttpStatusCode.Unauthorized)
257 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
259 throw new TwitterApiException(statusCode, responseText);
264 var error = TwitterError.ParseJson(responseText);
266 if (error?.Errors == null || error.Errors.Length == 0)
267 throw new TwitterApiException(statusCode, responseText);
269 var errorCodes = error.Errors.Select(x => x.Code);
270 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
272 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
275 throw new TwitterApiException(statusCode, error, responseText);
277 catch (SerializationException)
279 throw new TwitterApiException(statusCode, responseText);
283 public OAuthEchoHandler CreateOAuthEchoHandler(HttpMessageHandler innerHandler, Uri authServiceProvider, Uri? realm = null)
285 var uri = new Uri(RestApiBase, authServiceProvider);
287 if (this.Credential is TwitterCredentialOAuth1 oauthCredential)
289 return OAuthEchoHandler.CreateHandler(
292 oauthCredential.AppToken.OAuth1ConsumerKey,
293 oauthCredential.AppToken.OAuth1ConsumerSecret,
294 oauthCredential.Token,
295 oauthCredential.TokenSecret,
300 // MobipictureApi クラス向けの暫定対応
301 return OAuthEchoHandler.CreateHandler(
312 public void Dispose()
315 GC.SuppressFinalize(this);
318 protected virtual void Dispose(bool disposing)
323 this.IsDisposed = true;
327 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
332 ~TwitterApiConnection()
333 => this.Dispose(false);
335 private void Networking_WebProxyChanged(object sender, EventArgs e)
336 => this.InitializeHttpClients();
338 public static async Task<TwitterCredentialOAuth1> GetRequestTokenAsync(TwitterAppToken appToken)
340 var emptyCredential = new TwitterCredentialOAuth1(appToken, "", "");
341 var param = new Dictionary<string, string>
343 ["oauth_callback"] = "oob",
345 var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, emptyCredential)
346 .ConfigureAwait(false);
348 return new(appToken, response["oauth_token"], response["oauth_token_secret"]);
351 public static Uri GetAuthorizeUri(TwitterCredentialOAuth1 requestToken, string? screenName = null)
353 var param = new Dictionary<string, string>
355 ["oauth_token"] = requestToken.Token,
358 if (screenName != null)
359 param["screen_name"] = screenName;
361 return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
364 public static async Task<IDictionary<string, string>> GetAccessTokenAsync(TwitterCredentialOAuth1 credential, string verifier)
366 var param = new Dictionary<string, string>
368 ["oauth_verifier"] = verifier,
370 var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, credential)
371 .ConfigureAwait(false);
376 private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(
378 IDictionary<string, string> param,
379 TwitterCredentialOAuth1 credential
382 using var authorizeClient = InitializeHttpClient(credential);
384 var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
388 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
389 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
390 .ConfigureAwait(false);
392 using var content = response.Content;
393 var responseText = await content.ReadAsStringAsync()
394 .ConfigureAwait(false);
396 await TwitterApiConnection.CheckStatusCode(response)
397 .ConfigureAwait(false);
399 var responseParams = HttpUtility.ParseQueryString(responseText);
401 return responseParams.Cast<string>()
402 .ToDictionary(x => x, x => responseParams[x]);
404 catch (HttpRequestException ex)
406 throw TwitterApiException.CreateFromException(ex);
408 catch (OperationCanceledException ex)
410 throw TwitterApiException.CreateFromException(ex);
414 private static HttpClient InitializeHttpClient(ITwitterCredential credential)
416 var builder = Networking.CreateHttpClientBuilder();
418 builder.SetupHttpClientHandler(
419 x => x.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache)
422 builder.AddHandler(x => credential.CreateHttpHandler(x));
424 return builder.Build();