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 static async Task<T> HandleTimeout<T>(Func<CancellationToken, Task<T>> func, TimeSpan timeout)
178 using var cts = new CancellationTokenSource();
179 var cancellactionToken = cts.Token;
181 var task = Task.Run(() => func(cancellactionToken), cancellactionToken);
182 var timeoutTask = Task.Delay(timeout);
184 if (await Task.WhenAny(task, timeoutTask) == timeoutTask)
188 // キャンセル後のタスクで発生した例外は無視する
189 static async Task IgnoreExceptions(Task task)
193 await task.ConfigureAwait(false);
199 _ = IgnoreExceptions(task);
202 throw new OperationCanceledException("Timeout", cancellactionToken);
208 protected static async Task CheckStatusCode(HttpResponseMessage response)
210 var statusCode = response.StatusCode;
212 if ((int)statusCode >= 200 && (int)statusCode <= 299)
214 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid;
219 using (var content = response.Content)
221 responseText = await content.ReadAsStringAsync()
222 .ConfigureAwait(false);
225 if (string.IsNullOrWhiteSpace(responseText))
227 if (statusCode == HttpStatusCode.Unauthorized)
228 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
230 throw new TwitterApiException(statusCode, responseText);
235 var error = TwitterError.ParseJson(responseText);
237 if (error?.Errors == null || error.Errors.Length == 0)
238 throw new TwitterApiException(statusCode, responseText);
240 var errorCodes = error.Errors.Select(x => x.Code);
241 if (errorCodes.Any(x => x == TwitterErrorCode.InternalError || x == TwitterErrorCode.SuspendedAccount))
243 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
246 throw new TwitterApiException(statusCode, error, responseText);
248 catch (SerializationException)
250 throw new TwitterApiException(statusCode, responseText);
254 public OAuthEchoHandler CreateOAuthEchoHandler(HttpMessageHandler innerHandler, Uri authServiceProvider, Uri? realm = null)
256 var uri = new Uri(RestApiBase, authServiceProvider);
258 if (this.Credential is TwitterCredentialOAuth1 oauthCredential)
260 return OAuthEchoHandler.CreateHandler(
263 oauthCredential.AppToken.OAuth1ConsumerKey,
264 oauthCredential.AppToken.OAuth1ConsumerSecret,
265 oauthCredential.Token,
266 oauthCredential.TokenSecret,
271 // MobipictureApi クラス向けの暫定対応
272 return OAuthEchoHandler.CreateHandler(
283 public void Dispose()
286 GC.SuppressFinalize(this);
289 protected virtual void Dispose(bool disposing)
294 this.IsDisposed = true;
298 Networking.WebProxyChanged -= this.Networking_WebProxyChanged;
303 ~TwitterApiConnection()
304 => this.Dispose(false);
306 private void Networking_WebProxyChanged(object sender, EventArgs e)
307 => this.InitializeHttpClients();
309 public static async Task<TwitterCredentialOAuth1> GetRequestTokenAsync(TwitterAppToken appToken)
311 var emptyCredential = new TwitterCredentialOAuth1(appToken, "", "");
312 var param = new Dictionary<string, string>
314 ["oauth_callback"] = "oob",
316 var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/request_token"), param, emptyCredential)
317 .ConfigureAwait(false);
319 return new(appToken, response["oauth_token"], response["oauth_token_secret"]);
322 public static Uri GetAuthorizeUri(TwitterCredentialOAuth1 requestToken, string? screenName = null)
324 var param = new Dictionary<string, string>
326 ["oauth_token"] = requestToken.Token,
329 if (screenName != null)
330 param["screen_name"] = screenName;
332 return new Uri("https://api.twitter.com/oauth/authorize?" + MyCommon.BuildQueryString(param));
335 public static async Task<IDictionary<string, string>> GetAccessTokenAsync(TwitterCredentialOAuth1 credential, string verifier)
337 var param = new Dictionary<string, string>
339 ["oauth_verifier"] = verifier,
341 var response = await GetOAuthTokenAsync(new Uri("https://api.twitter.com/oauth/access_token"), param, credential)
342 .ConfigureAwait(false);
347 private static async Task<IDictionary<string, string>> GetOAuthTokenAsync(
349 IDictionary<string, string> param,
350 TwitterCredentialOAuth1 credential
353 using var authorizeClient = InitializeHttpClient(credential);
355 var requestUri = new Uri(uri, "?" + MyCommon.BuildQueryString(param));
359 using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
360 using var response = await authorizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
361 .ConfigureAwait(false);
363 using var content = response.Content;
364 var responseText = await content.ReadAsStringAsync()
365 .ConfigureAwait(false);
367 await TwitterApiConnection.CheckStatusCode(response)
368 .ConfigureAwait(false);
370 var responseParams = HttpUtility.ParseQueryString(responseText);
372 return responseParams.Cast<string>()
373 .ToDictionary(x => x, x => responseParams[x]);
375 catch (HttpRequestException ex)
377 throw TwitterApiException.CreateFromException(ex);
379 catch (OperationCanceledException ex)
381 throw TwitterApiException.CreateFromException(ex);
385 private static HttpClient InitializeHttpClient(ITwitterCredential credential)
387 var builder = Networking.CreateHttpClientBuilder();
389 builder.SetupHttpClientHandler(
390 x => x.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache)
393 builder.AddHandler(x => credential.CreateHttpHandler(x));
395 return builder.Build();