OSDN Git Service

翻訳機能で使用しているMicrosoft Translator APIのOAuth2認証に対応
authorKimura Youichi <kim.upsilon@bucyou.net>
Thu, 6 Oct 2016 15:08:52 +0000 (00:08 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Thu, 6 Oct 2016 15:18:17 +0000 (00:18 +0900)
OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs [new file with mode: 0644]
OpenTween.Tests/BingTest.cs
OpenTween.Tests/OpenTween.Tests.csproj
OpenTween/Api/MicrosoftTranslatorApi.cs [new file with mode: 0644]
OpenTween/ApplicationSettings.cs
OpenTween/Bing.cs
OpenTween/OpenTween.csproj
OpenTween/Properties/AssemblyInfo.cs
OpenTween/Resources/ChangeLog.txt

diff --git a/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs b/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs
new file mode 100644 (file)
index 0000000..5177d63
--- /dev/null
@@ -0,0 +1,202 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2016 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using Moq;
+using Xunit;
+
+namespace OpenTween.Api
+{
+    public class MicrosoftTranslatorApiTest
+    {
+        [Fact]
+        public async Task TranslateAsync_Test()
+        {
+            using (var mockHandler = new HttpMessageHandlerMock())
+            using (var http = new HttpClient(mockHandler))
+            {
+                var mock = new Mock<MicrosoftTranslatorApi>(http);
+                mock.Setup(x => x.GetAccessTokenAsync())
+                    .ReturnsAsync(Tuple.Create("1234abcd", TimeSpan.FromSeconds(1000)));
+
+                var translateApi = mock.Object;
+
+                mockHandler.Enqueue(x =>
+                {
+                    Assert.Equal(HttpMethod.Get, x.Method);
+                    Assert.Equal(MicrosoftTranslatorApi.TranslateEndpoint.AbsoluteUri,
+                        x.RequestUri.GetLeftPart(UriPartial.Path));
+
+                    var query = HttpUtility.ParseQueryString(x.RequestUri.Query);
+
+                    Assert.Equal("hogehoge", query["text"]);
+                    Assert.Equal("ja", query["to"]);
+                    Assert.Equal("en", query["from"]);
+
+                    return new HttpResponseMessage(HttpStatusCode.OK)
+                    {
+                        Content = new StringContent("ほげほげ"),
+                    };
+                });
+
+                var result = await translateApi.TranslateAsync("hogehoge", langTo: "ja", langFrom: "en")
+                    .ConfigureAwait(false);
+                Assert.Equal("ほげほげ", result);
+
+                mock.Verify(x => x.GetAccessTokenAsync(), Times.Once());
+                Assert.Equal(0, mockHandler.QueueCount);
+            }
+        }
+
+        [Fact]
+        public async Task UpdateAccessTokenIfExpired_FirstCallTest()
+        {
+            var mock = new Mock<MicrosoftTranslatorApi>();
+            mock.Setup(x => x.GetAccessTokenAsync())
+                .ReturnsAsync(Tuple.Create("1234abcd", TimeSpan.FromSeconds(1000)));
+
+            var translateApi = mock.Object;
+
+            await translateApi.UpdateAccessTokenIfExpired()
+                .ConfigureAwait(false);
+
+            Assert.Equal("1234abcd", translateApi.AccessToken);
+
+            // 期待値との差が 3 秒以内であるか
+            var expectedExpiresAt = DateTime.Now + TimeSpan.FromSeconds(1000 - 30);
+            Assert.True((translateApi.RefreshAccessTokenAt - expectedExpiresAt).Duration() < TimeSpan.FromSeconds(3));
+        }
+
+        [Fact]
+        public async Task UpdateAccessTokenIfExpired_NotExpiredTest()
+        {
+            var mock = new Mock<MicrosoftTranslatorApi>();
+
+            var translateApi = mock.Object;
+            translateApi.AccessToken = "1234abcd";
+            translateApi.RefreshAccessTokenAt = DateTime.Now + TimeSpan.FromMinutes(3);
+
+            await translateApi.UpdateAccessTokenIfExpired()
+                .ConfigureAwait(false);
+
+            // RefreshAccessTokenAt の時刻を過ぎるまでは GetAccessTokenAsync は呼ばれない
+            mock.Verify(x => x.GetAccessTokenAsync(), Times.Never());
+        }
+
+        [Fact]
+        public async Task UpdateAccessTokenIfExpired_ExpiredTest()
+        {
+            var mock = new Mock<MicrosoftTranslatorApi>();
+            mock.Setup(x => x.GetAccessTokenAsync())
+                .ReturnsAsync(Tuple.Create("5678efgh", TimeSpan.FromSeconds(1000)));
+
+            var translateApi = mock.Object;
+            translateApi.AccessToken = "1234abcd";
+            translateApi.RefreshAccessTokenAt = DateTime.Now - TimeSpan.FromMinutes(3);
+
+            await translateApi.UpdateAccessTokenIfExpired()
+                .ConfigureAwait(false);
+
+            Assert.Equal("5678efgh", translateApi.AccessToken);
+
+            // 期待値との差が 3 秒以内であるか
+            var expectedExpiresAt = DateTime.Now + TimeSpan.FromSeconds(1000 - 30);
+            Assert.True((translateApi.RefreshAccessTokenAt - expectedExpiresAt).Duration() < TimeSpan.FromSeconds(3));
+        }
+
+        [Fact]
+        public async Task GetAccessTokenAsync_Test()
+        {
+            using (var mockHandler = new HttpMessageHandlerMock())
+            using (var http = new HttpClient(mockHandler))
+            {
+                var translateApi = new MicrosoftTranslatorApi(http);
+
+                mockHandler.Enqueue(async x =>
+                {
+                    Assert.Equal(HttpMethod.Post, x.Method);
+                    Assert.Equal(MicrosoftTranslatorApi.OAuthEndpoint, x.RequestUri);
+
+                    var body = await x.Content.ReadAsStringAsync()
+                        .ConfigureAwait(false);
+                    var query = HttpUtility.ParseQueryString(body);
+
+                    Assert.Equal("client_credentials", query["grant_type"]);
+                    Assert.Equal(ApplicationSettings.AzureClientId, query["client_id"]);
+                    Assert.Equal(ApplicationSettings.AzureClientSecret, query["client_secret"]);
+                    Assert.Equal("http://api.microsofttranslator.com", query["scope"]);
+
+                    return new HttpResponseMessage(HttpStatusCode.OK)
+                    {
+                        Content = new StringContent(@"
+{
+  ""access_token"": ""12345acbde"",
+  ""token_type"": ""bearer"",
+  ""expires_in"": 3600
+}"),
+                    };
+                });
+
+                var result = await translateApi.GetAccessTokenAsync()
+                    .ConfigureAwait(false);
+
+                var expectedToken = Tuple.Create(@"12345acbde", TimeSpan.FromSeconds(3600));
+                Assert.Equal(expectedToken, result);
+
+                Assert.Equal(0, mockHandler.QueueCount);
+            }
+        }
+
+        [Fact]
+        public void ParseOAuthCredential_ValidResponseTest()
+        {
+            var jsonBytes = Encoding.UTF8.GetBytes(@"
+{
+  ""access_token"": ""12345acbde"",
+  ""token_type"": ""bearer"",
+  ""expires_in"": 3600
+}
+");
+            var expected = Tuple.Create(@"12345acbde", TimeSpan.FromSeconds(3600));
+            Assert.Equal(expected, MicrosoftTranslatorApi.ParseOAuthCredential(jsonBytes));
+        }
+
+        [Fact]
+        public void ParseOAuthCredential_OmittedExpiresInTest()
+        {
+            var jsonBytes = Encoding.UTF8.GetBytes(@"
+{
+  ""access_token"": ""12345acbde"",
+  ""token_type"": ""bearer""
+}
+");
+            var expected = Tuple.Create(@"12345acbde", TimeSpan.Zero);
+            Assert.Equal(expected, MicrosoftTranslatorApi.ParseOAuthCredential(jsonBytes));
+        }
+    }
+}
index 2f25455..8cff6c3 100644 (file)
@@ -36,53 +36,6 @@ namespace OpenTween
     /// </summary>
     public class BingTest
     {
-        [Fact]
-        public async Task TranslateAsync_Test()
-        {
-            var handler = new HttpMessageHandlerMock();
-            var bing = new Bing(new HttpClient(handler));
-
-            handler.Enqueue(x =>
-            {
-                Assert.Equal(HttpMethod.Get, x.Method);
-                Assert.Equal("https://api.datamarket.azure.com/Data.ashx/Bing/MicrosoftTranslator/v1/Translate",
-                    x.RequestUri.GetLeftPart(UriPartial.Path));
-
-                var query = HttpUtility.ParseQueryString(x.RequestUri.Query);
-
-                Assert.Equal("'hogehoge'", query["Text"]);
-                Assert.Equal("'ja'", query["To"]);
-                Assert.Equal("Raw", query["$format"]);
-
-                return new HttpResponseMessage(HttpStatusCode.OK)
-                {
-                    Content = new StringContent("<string>ほげほげ</string>"),
-                };
-            });
-
-            var translatedText = await bing.TranslateAsync("hogehoge", langFrom: null, langTo: "ja");
-            Assert.Equal("ほげほげ", translatedText);
-
-            Assert.Equal(0, handler.QueueCount);
-        }
-
-        [Fact]
-        public async Task TranslateAsync_HttpErrorTest()
-        {
-            var handler = new HttpMessageHandlerMock();
-            var bing = new Bing(new HttpClient(handler));
-
-            handler.Enqueue(x =>
-            {
-                return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
-            });
-
-            await Assert.ThrowsAsync<HttpRequestException>(async () =>
-                await bing.TranslateAsync("hogehoge", langFrom: null, langTo: "ja"));
-
-            Assert.Equal(0, handler.QueueCount);
-        }
-
         [Theory]
         [InlineData("af", 0)]
         [InlineData("sq", 1)]
@@ -100,14 +53,5 @@ namespace OpenTween
         {
             Assert.Equal(expected, Bing.GetIndexFromLanguageEnum(lang));
         }
-
-        [Fact]
-        public void CreateBasicAuthHeaderValue_Test()
-        {
-            var value = Bing.CreateBasicAuthHeaderValue("user", "pass");
-
-            Assert.Equal("Basic", value.Scheme);
-            Assert.Equal("user:pass", Encoding.UTF8.GetString(Convert.FromBase64String(value.Parameter)));
-        }
     }
 }
index c8d9e57..344e021 100644 (file)
@@ -73,6 +73,7 @@
   <ItemGroup>
     <Compile Include="AnyOrderComparer.cs" />
     <Compile Include="Api\ApiLimitTest.cs" />
+    <Compile Include="Api\MicrosoftTranslatorApiTest.cs" />
     <Compile Include="Api\TwitterApiStatusTest.cs" />
     <Compile Include="Api\TwitterApiTest.cs" />
     <Compile Include="BingTest.cs" />
diff --git a/OpenTween/Api/MicrosoftTranslatorApi.cs b/OpenTween/Api/MicrosoftTranslatorApi.cs
new file mode 100644 (file)
index 0000000..f6d4ca4
--- /dev/null
@@ -0,0 +1,157 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2016 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Runtime.Serialization.Json;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using OpenTween.Connection;
+
+namespace OpenTween.Api
+{
+    public class MicrosoftTranslatorApi
+    {
+        public static readonly Uri OAuthEndpoint = new Uri("https://datamarket.accesscontrol.windows.net/v2/OAuth2-13");
+        public static readonly Uri TranslateEndpoint = new Uri("https://api.microsofttranslator.com/v2/Http.svc/Translate");
+
+        public string AccessToken { get; internal set; }
+        public DateTime RefreshAccessTokenAt { get; internal set; }
+
+        private HttpClient Http => this.localHttpClient ?? Networking.Http;
+        private readonly HttpClient localHttpClient;
+
+        public MicrosoftTranslatorApi()
+            : this(null)
+        {
+        }
+
+        public MicrosoftTranslatorApi(HttpClient http)
+        {
+            this.localHttpClient = http;
+        }
+
+        public async Task<string> TranslateAsync(string text, string langTo, string langFrom = null)
+        {
+            await this.UpdateAccessTokenIfExpired()
+                .ConfigureAwait(false);
+
+            var param = new Dictionary<string, string>
+            {
+                ["text"] = text,
+                ["to"] = langTo,
+            };
+
+            if (langFrom != null)
+                param["from"] = langFrom;
+
+            var requestUri = new Uri(TranslateEndpoint, "?" + MyCommon.BuildQueryString(param));
+
+            using (var request = new HttpRequestMessage(HttpMethod.Get, requestUri))
+            {
+                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken);
+
+                using (var response = await this.Http.SendAsync(request).ConfigureAwait(false))
+                {
+                    return await response.Content.ReadAsStringAsync()
+                        .ConfigureAwait(false);
+                }
+            }
+        }
+
+        public async Task UpdateAccessTokenIfExpired()
+        {
+            if (this.AccessToken != null && this.RefreshAccessTokenAt > DateTime.Now)
+                return;
+
+            var accessToken = await this.GetAccessTokenAsync()
+                .ConfigureAwait(false);
+
+            this.AccessToken = accessToken.Item1;
+
+            // expires_in の示す時刻より 30 秒早めに再発行する
+            this.RefreshAccessTokenAt = DateTime.Now + accessToken.Item2 - TimeSpan.FromSeconds(30);
+        }
+
+        internal virtual async Task<Tuple<string, TimeSpan>> GetAccessTokenAsync()
+        {
+            var param = new Dictionary<string, string>
+            {
+                ["grant_type"] = "client_credentials",
+                ["client_id"] = ApplicationSettings.AzureClientId,
+                ["client_secret"] = ApplicationSettings.AzureClientSecret,
+                ["scope"] = "http://api.microsofttranslator.com",
+            };
+
+            using (var request = new HttpRequestMessage(HttpMethod.Post, OAuthEndpoint))
+            using (var postContent = new FormUrlEncodedContent(param))
+            {
+                request.Content = postContent;
+
+                using (var response = await this.Http.SendAsync(request).ConfigureAwait(false))
+                {
+                    var responseBytes = await response.Content.ReadAsByteArrayAsync()
+                        .ConfigureAwait(false);
+
+                    return ParseOAuthCredential(responseBytes);
+                }
+            }
+        }
+
+        internal static Tuple<string, TimeSpan> ParseOAuthCredential(byte[] responseBytes)
+        {
+            using (var jsonReader = JsonReaderWriterFactory.CreateJsonReader(responseBytes, XmlDictionaryReaderQuotas.Max))
+            {
+                var xElm = XElement.Load(jsonReader);
+
+                var tokenTypeElm = xElm.Element("token_type");
+                if (tokenTypeElm == null)
+                    throw new WebApiException("Property `token_type` required");
+
+                var accessTokenElm = xElm.Element("access_token");
+                if (accessTokenElm == null)
+                    throw new WebApiException("Property `access_token` required");
+
+                var expiresInElm = xElm.Element("expires_in");
+
+                int expiresInSeconds;
+                if (expiresInElm != null)
+                {
+                    if (!int.TryParse(expiresInElm.Value, out expiresInSeconds))
+                        throw new WebApiException("Invalid number: expires_in = " + expiresInElm.Value);
+                }
+                else
+                {
+                    // expires_in が省略された場合は有効期間が不明なので、
+                    // 次回のリクエスト時は経過時間に関わらずアクセストークンの再発行を行う
+                    expiresInSeconds = 0;
+                }
+
+                return Tuple.Create(accessTokenElm.Value, TimeSpan.FromSeconds(expiresInSeconds));
+            }
+        }
+    }
+}
index 3dd8a9c..00a8377 100644 (file)
@@ -142,12 +142,17 @@ namespace OpenTween
 
         //=====================================================================
         // Windows Azure Marketplace
-        // https://datamarket.azure.com/account/keys から取得できます。
+        // https://datamarket.azure.com/developer/applications から取得できます。
 
         /// <summary>
-        /// Windows Azure Marketplace アカウントキー
+        /// Windows Azure Marketplace Client Id
         /// </summary>
-        public const string AzureMarketplaceKey = "UlOODyR2rVH0lfweya1VuY5KjE7L0ZjvQKQWlYgWsPw=";
+        public readonly static string AzureClientId = "OpenTween";
+
+        /// <summary>
+        /// Windows Azure Marketplace Client Secret
+        /// </summary>
+        public readonly static string AzureClientSecret = "UiTaBcOkGxCpjloU/2W3P0fTiHNz+FUeGuzgUz2rwZU=";
 
         //=====================================================================
         // Imgur
index afd5f65..84917cf 100644 (file)
 using System;
 using System.Collections.Generic;
 using System.Net.Http;
-using System.Net.Http.Headers;
 using System.Text;
+using System.Threading;
 using System.Threading.Tasks;
-using System.Web;
-using System.Xml.Linq;
+using OpenTween.Api;
 using OpenTween.Connection;
 
 namespace OpenTween
@@ -166,14 +165,7 @@ namespace OpenTween
             "zu",
         };
 
-        private static readonly string TranslateUri =
-            "https://api.datamarket.azure.com/Data.ashx/Bing/MicrosoftTranslator/v1/Translate";
-
-        protected HttpClient http
-        {
-            get { return this.localHttpClient ?? Networking.Http; }
-        }
-        private readonly HttpClient localHttpClient;
+        private readonly MicrosoftTranslatorApi translatorApi;
 
         public Bing()
             : this(null)
@@ -182,7 +174,7 @@ namespace OpenTween
 
         public Bing(HttpClient http)
         {
-            this.localHttpClient = http;
+            this.translatorApi = new MicrosoftTranslatorApi(http);
         }
 
         /// <summary>
@@ -191,31 +183,8 @@ namespace OpenTween
         /// <exception cref="HttpRequestException"/>
         public async Task<string> TranslateAsync(string text, string langFrom, string langTo)
         {
-            var param = new Dictionary<string, string>
-            {
-                ["Text"] = "'" + text + "'",
-                ["To"] = "'" + langTo + "'",
-                ["$format"] = "Raw",
-            };
-
-            if (langFrom != null)
-                param["From"] = "'" + langFrom + "'";
-
-            var uri = new Uri(TranslateUri + "?" + MyCommon.BuildQueryString(param));
-            var request = new HttpRequestMessage(HttpMethod.Get, uri);
-            request.Headers.Authorization = CreateBasicAuthHeaderValue(ApplicationSettings.AzureMarketplaceKey, ApplicationSettings.AzureMarketplaceKey);
-
-            using (var response = await this.http.SendAsync(request).ConfigureAwait(false))
-            {
-                response.EnsureSuccessStatusCode();
-
-                var xmlStr = await response.Content.ReadAsStringAsync()
-                    .ConfigureAwait(false);
-
-                var xdoc = XDocument.Parse(xmlStr);
-
-                return xdoc.Root.Value;
-            }
+            return await this.translatorApi.TranslateAsync(text, langTo, langFrom)
+                .ConfigureAwait(false);
         }
 
         public static string GetLanguageEnumFromIndex(int index)
@@ -227,11 +196,5 @@ namespace OpenTween
         {
             return LanguageTable.IndexOf(lang);
         }
-
-        internal static AuthenticationHeaderValue CreateBasicAuthHeaderValue(string user, string pass)
-        {
-            var paramBytes = Encoding.UTF8.GetBytes(user + ":" + pass);
-            return new AuthenticationHeaderValue("Basic", Convert.ToBase64String(paramBytes));
-        }
     }
 }
index d53442d..c46d139 100644 (file)
@@ -86,6 +86,7 @@
     <Compile Include="Api\DataModel\TwitterUploadMediaResult.cs" />
     <Compile Include="Api\DataModel\TwitterUser.cs" />
     <Compile Include="Api\DataModel\TwitterApiAccessLevel.cs" />
+    <Compile Include="Api\MicrosoftTranslatorApi.cs" />
     <Compile Include="Api\TwitterApi.cs" />
     <Compile Include="Api\TwitterApiException.cs" />
     <Compile Include="Api\TwitterApiStatus.cs" />
index d5b325a..aa4b8dc 100644 (file)
@@ -35,4 +35,5 @@ using System.Runtime.InteropServices;
 [assembly: AssemblyVersion("0.1.0.0")]
 [assembly: AssemblyFileVersion("1.3.5.1")]
 
-[assembly: InternalsVisibleTo("OpenTween.Tests")]
\ No newline at end of file
+[assembly: InternalsVisibleTo("OpenTween.Tests")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq
index efa41bd..67efe31 100644 (file)
@@ -3,6 +3,7 @@
 ==== Ver 1.3.6-dev(2016/xx/xx)
  * FIX: 発言詳細部のプロフィール画像に誤ったユーザーの画像が表示されることがある不具合を修正
  * FIX: ツイートURLのコピー時に余分な改行文字が末尾に付く不具合を修正
+ * FIX: Bing翻訳機能が使用できなくなっていた問題を修正
 
 ==== Ver 1.3.5(2016/10/01)
  * NEW: 140文字を越えるツイートの表示に対応しました