LEFT | RIGHT |
1 /* | 1 /* |
2 Copyright 2013 Google Inc | 2 Copyright 2013 Google Inc |
3 | 3 |
4 Licensed under the Apache License, Version 2.0 (the "License"); | 4 Licensed under the Apache License, Version 2.0 (the "License"); |
5 you may not use this file except in compliance with the License. | 5 you may not use this file except in compliance with the License. |
6 You may obtain a copy of the License at | 6 You may obtain a copy of the License at |
7 | 7 |
8 http://www.apache.org/licenses/LICENSE-2.0 | 8 http://www.apache.org/licenses/LICENSE-2.0 |
9 | 9 |
10 Unless required by applicable law or agreed to in writing, software | 10 Unless required by applicable law or agreed to in writing, software |
11 distributed under the License is distributed on an "AS IS" BASIS, | 11 distributed under the License is distributed on an "AS IS" BASIS, |
12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
13 See the License for the specific language governing permissions and | 13 See the License for the specific language governing permissions and |
14 limitations under the License. | 14 limitations under the License. |
15 */ | 15 */ |
16 | 16 |
17 using System; | 17 using System; |
18 using System.Collections.Generic; | 18 using System.Collections.Generic; |
19 using System.IO; | 19 using System.IO; |
20 using System.Net.Http; | 20 using System.Net.Http; |
21 using System.Threading; | 21 using System.Threading; |
22 using System.Threading.Tasks; | 22 using System.Threading.Tasks; |
23 | 23 |
24 using Google.Apis.Auth.OAuth2.Requests; | 24 using Google.Apis.Auth.OAuth2.Requests; |
25 using Google.Apis.Auth.OAuth2.Responses; | 25 using Google.Apis.Auth.OAuth2.Responses; |
26 using Google.Apis.Http; | 26 using Google.Apis.Http; |
27 using Google.Apis.Json; | |
28 using Google.Apis.Logging; | 27 using Google.Apis.Logging; |
29 using Google.Apis.Requests.Parameters; | |
30 using Google.Apis.Util; | 28 using Google.Apis.Util; |
31 using Google.Apis.Util.Store; | 29 using Google.Apis.Util.Store; |
32 using Google.Apis.Testing; | 30 using Google.Apis.Testing; |
33 | 31 |
34 namespace Google.Apis.Auth.OAuth2 | 32 namespace Google.Apis.Auth.OAuth2 |
35 { | 33 { |
36 /// <summary> | 34 /// <summary> |
37 /// Thread-safe OAuth 2.0 authorization code flow that manages and persists
end-user credentials. | 35 /// Thread-safe OAuth 2.0 authorization code flow that manages and persists
end-user credentials. |
38 /// <para> | 36 /// <para> |
39 /// This is designed to simplify the flow in which an end-user authorizes th
e application to access their protected | 37 /// This is designed to simplify the flow in which an end-user authorizes th
e application to access their protected |
40 /// data, and then the application has access to their data based on an acce
ss token and a refresh token to refresh· | 38 /// data, and then the application has access to their data based on an acce
ss token and a refresh token to refresh· |
41 /// that access token when it expires. | 39 /// that access token when it expires. |
42 /// </para> | 40 /// </para> |
43 /// </summary> | 41 /// </summary> |
44 public class AuthorizationCodeFlow : IAuthorizationCodeFlow | 42 public class AuthorizationCodeFlow : IAuthorizationCodeFlow |
45 { | 43 { |
46 private static readonly ILogger Logger = ApplicationContext.Logger.ForTy
pe<AuthorizationCodeFlow>(); | 44 private static readonly ILogger Logger = ApplicationContext.Logger.ForTy
pe<AuthorizationCodeFlow>(); |
47 | 45 |
48 #region Initializer | 46 #region Initializer |
49 | 47 |
50 /// <summary>An initializer class for the authorization code flow. </sum
mary> | 48 /// <summary>An initializer class for the authorization code flow. </sum
mary> |
51 public class Initializer | 49 public class Initializer |
52 { | 50 { |
53 /// <summary> | 51 /// <summary> |
54 /// Gets or sets the method for presenting the access token to the r
esource server. | 52 /// Gets or sets the method for presenting the access token to the r
esource server. |
55 /// The default value is <seealso cref="BearerToken.AuthorizationHea
derAccessMethod"/>. | 53 /// The default value is <seealso cref="BearerToken.AuthorizationHea
derAccessMethod"/>. |
56 /// </summary> | 54 /// </summary> |
57 public IAccessMethod AccessMethod { get; set; } | 55 public IAccessMethod AccessMethod { get; set; } |
58 | 56 |
59 /// <summary>Gets or sets the token server URL.</summary> | 57 /// <summary>Gets the token server URL.</summary> |
60 public string TokenServerUrl { get; private set; } | 58 public string TokenServerUrl { get; private set; } |
61 | 59 |
62 /// <summary>Gets or sets the authorization server URL.</summary> | 60 /// <summary>Gets or sets the authorization server URL.</summary> |
63 public string AuthorizationServerUrl { get; private set; } | 61 public string AuthorizationServerUrl { get; private set; } |
64 | 62 |
65 /// <summary>Gets or sets the client secrets which includes the clie
nt identifier and its secret.</summary> | 63 /// <summary>Gets or sets the client secrets which includes the clie
nt identifier and its secret.</summary> |
66 public ClientSecrets ClientSecrets { get; set; } | 64 public ClientSecrets ClientSecrets { get; set; } |
67 | 65 |
68 /// <summary> | 66 /// <summary> |
69 /// Gets or sets the client secrets stream which contains the client
identifier and its secret. | 67 /// Gets or sets the client secrets stream which contains the client
identifier and its secret. |
70 /// </summary> | 68 /// </summary> |
71 /// <remarks>The AuthorizationCodeFlow constructor is responsible fo
r disposing the stream.</remarks> | 69 /// <remarks>The AuthorizationCodeFlow constructor is responsible fo
r disposing the stream.</remarks> |
72 public Stream ClientSecretsStream { get; set; } | 70 public Stream ClientSecretsStream { get; set; } |
73 | 71 |
74 /// <summary>Gets or sets the data store used to store the token res
ponse.</summary> | 72 /// <summary>Gets or sets the data store used to store the token res
ponse.</summary> |
75 public IDataStore DataStore { get; set; } | 73 public IDataStore DataStore { get; set; } |
76 | 74 |
77 /// <summary>Gets or sets the scopes.</summary> | 75 /// <summary> |
| 76 /// Gets or sets the scopes which indicate the API access your appli
cation is requesting. |
| 77 /// </summary> |
78 public IEnumerable<string> Scopes { get; set; } | 78 public IEnumerable<string> Scopes { get; set; } |
79 | 79 |
80 /// <summary>· | 80 /// <summary>· |
81 /// Gets or sets the factory for creating <see cref="System.Net.Http
.HttpClient"/> instance. | 81 /// Gets or sets the factory for creating <see cref="System.Net.Http
.HttpClient"/> instance. |
82 /// </summary> | 82 /// </summary> |
83 public IHttpClientFactory HttpClientFactory { get; set; } | 83 public IHttpClientFactory HttpClientFactory { get; set; } |
84 | 84 |
85 /// <summary> | 85 /// <summary> |
86 /// Get or sets the exponential back-off policy. Default value is <
c>UnsuccessfulResponse503</c>, which· | 86 /// Get or sets the exponential back-off policy. Default value is <
c>UnsuccessfulResponse503</c>, which· |
87 /// means that exponential back-off is used on 503 abnormal HTTP res
ponses. | 87 /// means that exponential back-off is used on 503 abnormal HTTP res
ponses. |
(...skipping 34 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
122 private readonly string authorizationServerUrl; | 122 private readonly string authorizationServerUrl; |
123 private readonly ClientSecrets clientSecrets; | 123 private readonly ClientSecrets clientSecrets; |
124 private readonly IDataStore dataStore; | 124 private readonly IDataStore dataStore; |
125 private readonly IEnumerable<string> scopes; | 125 private readonly IEnumerable<string> scopes; |
126 private readonly ConfigurableHttpClient httpClient; | 126 private readonly ConfigurableHttpClient httpClient; |
127 private readonly IClock clock; | 127 private readonly IClock clock; |
128 | 128 |
129 #endregion | 129 #endregion |
130 | 130 |
131 /// <summary>Gets the token server URL.</summary> | 131 /// <summary>Gets the token server URL.</summary> |
132 public string TokenServerEncodedUrl { get { return tokenServerUrl; } } | 132 public string TokenServerUrl { get { return tokenServerUrl; } } |
133 | 133 |
134 /// <summary>Gets the authorization code server URL.</summary> | 134 /// <summary>Gets the authorization code server URL.</summary> |
135 public string AuthorizationServerUrl { get { return authorizationServerU
rl; } } | 135 public string AuthorizationServerUrl { get { return authorizationServerU
rl; } } |
136 | 136 |
137 /// <summary>Gets the client secrets which includes the client identifie
r and its secret.</summary> | 137 /// <summary>Gets the client secrets which includes the client identifie
r and its secret.</summary> |
138 public ClientSecrets ClientSecrets { get { return clientSecrets; } } | 138 public ClientSecrets ClientSecrets { get { return clientSecrets; } } |
139 | 139 |
140 /// <summary>Gets the data store used to store the credentials.</summary
> | 140 /// <summary>Gets the data store used to store the credentials.</summary
> |
141 public IDataStore DataStore { get { return dataStore; } } | 141 public IDataStore DataStore { get { return dataStore; } } |
142 | 142 |
143 /// <summary>Gets the scopes.</summary> | 143 /// <summary>Gets the scopes which indicate the API access your applicat
ion is requesting.</summary> |
144 public IEnumerable<string> Scopes { get { return scopes; } } | 144 public IEnumerable<string> Scopes { get { return scopes; } } |
145 | 145 |
146 /// <summary>Gets the HTTP client used to make authentication requests t
o the server.</summary> | 146 /// <summary>Gets the HTTP client used to make authentication requests t
o the server.</summary> |
147 public ConfigurableHttpClient HttpClient { get { return httpClient; } } | 147 public ConfigurableHttpClient HttpClient { get { return httpClient; } } |
148 | 148 |
149 /// <summary>Constructs a new flow using the initializer's properties.</
summary> | 149 /// <summary>Constructs a new flow using the initializer's properties.</
summary> |
150 public AuthorizationCodeFlow(Initializer initializer) | 150 public AuthorizationCodeFlow(Initializer initializer) |
151 { | 151 { |
152 clientSecrets = initializer.ClientSecrets; | 152 clientSecrets = initializer.ClientSecrets; |
153 if (clientSecrets == null) | 153 if (clientSecrets == null) |
154 { | 154 { |
155 if (initializer.ClientSecretsStream == null) | 155 if (initializer.ClientSecretsStream == null) |
156 { | 156 { |
157 throw new ArgumentException( | 157 throw new ArgumentException("You MUST set ClientSecret or Cl
ientSecretStream on the initializer"); |
158 "At least one of the client secret or client secret stre
am MUST be set"); | |
159 } | 158 } |
160 | 159 |
161 try | 160 using (initializer.ClientSecretsStream) |
162 { | 161 { |
163 using (initializer.ClientSecretsStream) | 162 clientSecrets = GoogleClientSecrets.Load(initializer.ClientS
ecretsStream).Secrets; |
164 { | |
165 clientSecrets = GoogleClientSecrets.Load(initializer.Cli
entSecretsStream).Secrets; | |
166 } | |
167 } | 163 } |
168 catch (Exception ex) | 164 } |
169 { | 165 |
170 throw new ArgumentException("Can't read the client secrets f
rom the given stream", ex); | 166 accessMethod = initializer.AccessMethod.ThrowIfNull("Initializer.Acc
essMethod"); |
171 } | 167 clock = initializer.Clock.ThrowIfNull("Initializer.Clock"); |
172 } | 168 tokenServerUrl = initializer.TokenServerUrl.ThrowIfNullOrEmpty("Init
ializer.TokenServerUrl"); |
173 initializer.AccessMethod.ThrowIfNull("AccessMethod"); | |
174 initializer.ClientSecrets.ThrowIfNull("Clock"); | |
175 | |
176 accessMethod = initializer.AccessMethod; | |
177 tokenServerUrl = initializer.TokenServerUrl.ThrowIfNullOrEmpty("Toke
nServerUrl"); | |
178 authorizationServerUrl = initializer.AuthorizationServerUrl.ThrowIfN
ullOrEmpty | 169 authorizationServerUrl = initializer.AuthorizationServerUrl.ThrowIfN
ullOrEmpty |
179 ("AuthorizationServerUrl"); | 170 ("Initializer.AuthorizationServerUrl"); |
180 clock = initializer.Clock; | |
181 | 171 |
182 dataStore = initializer.DataStore; | 172 dataStore = initializer.DataStore; |
183 if (dataStore == null) | 173 if (dataStore == null) |
184 { | 174 { |
185 Logger.Warning("Datastore is null, as a result the user's creden
tial will not be stored"); | 175 Logger.Warning("Datastore is null, as a result the user's creden
tial will not be stored"); |
186 } | 176 } |
187 scopes = initializer.Scopes; | 177 scopes = initializer.Scopes; |
188 | 178 |
189 // Set the HTTP client. | 179 // Set the HTTP client. |
190 var httpArgs = new CreateHttpClientArgs(); | 180 var httpArgs = new CreateHttpClientArgs(); |
191 | 181 |
192 // Add exponential back-off initializer if necessary. | 182 // Add exponential back-off initializer if necessary. |
193 if (initializer.DefaultExponentialBackOffPolicy != ExponentialBackOf
fPolicy.None) | 183 if (initializer.DefaultExponentialBackOffPolicy != ExponentialBackOf
fPolicy.None) |
194 { | 184 { |
195 httpArgs.Initializers.Add( | 185 httpArgs.Initializers.Add( |
196 new ExponentialBackOffInitializer(initializer.DefaultExponen
tialBackOffPolicy, | 186 new ExponentialBackOffInitializer(initializer.DefaultExponen
tialBackOffPolicy, |
197 () => new BackOffHandler(new ExponentialBackOff()))); | 187 () => new BackOffHandler(new ExponentialBackOff()))); |
198 } | 188 } |
199 httpClient = (initializer.HttpClientFactory ?? new HttpClientFactory
()).CreateHttpClient(httpArgs); | 189 httpClient = (initializer.HttpClientFactory ?? new HttpClientFactory
()).CreateHttpClient(httpArgs); |
200 } | 190 } |
201 | 191 |
202 #region IAuthorizationCodeFlow overrides | 192 #region IAuthorizationCodeFlow overrides |
203 | 193 |
204 public IAccessMethod AccessMethod { get { return accessMethod; } } | 194 public IAccessMethod AccessMethod { get { return accessMethod; } } |
205 | 195 |
206 public IClock Clock { get { return clock; } } | 196 public IClock Clock { get { return clock; } } |
207 | 197 |
208 public async Task<TokenResponse> LoadToken(string userId, CancellationTo
ken taskCancellationToken) | 198 public async Task<TokenResponse> LoadTokenAsync(string userId, Cancellat
ionToken taskCancellationToken) |
209 { | 199 { |
210 taskCancellationToken.ThrowIfCancellationRequested(); | 200 taskCancellationToken.ThrowIfCancellationRequested(); |
211 if (DataStore == null) | 201 if (DataStore == null) |
212 { | 202 { |
213 return null; | 203 return null; |
214 } | 204 } |
215 return await DataStore.Get<TokenResponse>(userId).ConfigureAwait(fal
se); | 205 return await DataStore.GetAsync<TokenResponse>(userId).ConfigureAwai
t(false); |
216 } | 206 } |
217 | 207 |
218 public async Task DeleteToken(string userId, CancellationToken taskCance
llationToken) | 208 public async Task DeleteTokenAsync(string userId, CancellationToken task
CancellationToken) |
219 { | 209 { |
220 taskCancellationToken.ThrowIfCancellationRequested(); | 210 taskCancellationToken.ThrowIfCancellationRequested(); |
221 if (DataStore != null) | 211 if (DataStore != null) |
222 { | 212 { |
223 await DataStore.Delete<TokenResponse>(userId).ConfigureAwait(fal
se); | 213 await DataStore.DeleteAsync<TokenResponse>(userId).ConfigureAwai
t(false); |
224 } | 214 } |
225 } | 215 } |
226 | 216 |
227 public virtual AuthorizationCodeRequestUrl CreateAuthorizationCodeReques
t(string redirectUri) | 217 public virtual AuthorizationCodeRequestUrl CreateAuthorizationCodeReques
t(string redirectUri) |
228 { | 218 { |
229 return new AuthorizationCodeRequestUrl(new Uri(AuthorizationServerUr
l)) | 219 return new AuthorizationCodeRequestUrl(new Uri(AuthorizationServerUr
l)) |
230 { | 220 { |
231 ClientId = ClientSecrets.ClientId, | 221 ClientId = ClientSecrets.ClientId, |
232 Scope = string.Join(" ", Scopes), | 222 Scope = string.Join(" ", Scopes), |
233 RedirectUri = redirectUri | 223 RedirectUri = redirectUri |
234 }; | 224 }; |
235 } | 225 } |
236 | 226 |
237 public async Task<TokenResponse> ExchangeCodeForToken(string userId, str
ing code, string redirectUri, | 227 public async Task<TokenResponse> ExchangeCodeForTokenAsync(string userId
, string code, string redirectUri, |
238 CancellationToken taskCancellationToken) | 228 CancellationToken taskCancellationToken) |
239 { | 229 { |
240 taskCancellationToken.ThrowIfCancellationRequested(); | |
241 var authorizationCodeTokenReq = new AuthorizationCodeTokenRequest | 230 var authorizationCodeTokenReq = new AuthorizationCodeTokenRequest |
242 { | 231 { |
243 Scope = string.Join(" ", Scopes), | 232 Scope = string.Join(" ", Scopes), |
244 RedirectUri = redirectUri, | 233 RedirectUri = redirectUri, |
245 Code = code, | 234 Code = code, |
246 }; | 235 }; |
247 | 236 |
248 var token = await FetchToken(userId, authorizationCodeTokenReq, task
CancellationToken) | 237 var token = await FetchTokenAsync(userId, authorizationCodeTokenReq,
taskCancellationToken) |
249 .ConfigureAwait(false); | 238 .ConfigureAwait(false); |
250 await StoreToken(userId, token, taskCancellationToken).ConfigureAwai
t(false); | 239 await StoreTokenAsync(userId, token, taskCancellationToken).Configur
eAwait(false); |
251 return token; | 240 return token; |
252 } | 241 } |
253 | 242 |
254 public async Task<TokenResponse> RefreshToken(string userId, string refr
eshToken, | 243 public async Task<TokenResponse> RefreshTokenAsync(string userId, string
refreshToken, |
255 CancellationToken taskCancellationToken) | 244 CancellationToken taskCancellationToken) |
256 { | 245 { |
257 taskCancellationToken.ThrowIfCancellationRequested(); | |
258 var refershTokenReq = new RefreshTokenRequest | 246 var refershTokenReq = new RefreshTokenRequest |
259 { | 247 { |
260 RefreshToken = refreshToken, | 248 RefreshToken = refreshToken, |
261 }; | 249 }; |
262 var token = await FetchToken(userId, refershTokenReq, taskCancellati
onToken).ConfigureAwait(false); | 250 var token = await FetchTokenAsync(userId, refershTokenReq, taskCance
llationToken).ConfigureAwait(false); |
263 | 251 |
264 // The new token may not contain a refresh token, so set it with the
given refresh token. | 252 // The new token may not contain a refresh token, so set it with the
given refresh token. |
265 if (token.RefreshToken == null) | 253 if (token.RefreshToken == null) |
266 { | 254 { |
267 token.RefreshToken = refreshToken; | 255 token.RefreshToken = refreshToken; |
268 } | 256 } |
269 | 257 |
270 await StoreToken(userId, token, taskCancellationToken).ConfigureAwai
t(false); | 258 await StoreTokenAsync(userId, token, taskCancellationToken).Configur
eAwait(false); |
271 return token; | 259 return token; |
272 } | 260 } |
273 | 261 |
274 #endregion | 262 #endregion |
275 | 263 |
276 /// <summary>Stores the token in the <see cref="DataStore"/>.</summary> | 264 /// <summary>Stores the token in the <see cref="DataStore"/>.</summary> |
277 /// <param name="userId">User identifier</param> | 265 /// <param name="userId">User identifier</param> |
278 /// <param name="token">Token to store</param> | 266 /// <param name="token">Token to store</param> |
279 /// <param name="taskCancellationToken">Cancellation token to cancel ope
ration</param> | 267 /// <param name="taskCancellationToken">Cancellation token to cancel ope
ration</param> |
280 private async Task StoreToken(string userId, TokenResponse token, Cancel
lationToken taskCancellationToken) | 268 private async Task StoreTokenAsync(string userId, TokenResponse token, C
ancellationToken taskCancellationToken) |
281 { | 269 { |
282 taskCancellationToken.ThrowIfCancellationRequested(); | 270 taskCancellationToken.ThrowIfCancellationRequested(); |
283 if (DataStore != null) | 271 if (DataStore != null) |
284 { | 272 { |
285 await DataStore.Store<TokenResponse>(userId, token).ConfigureAwa
it(false); | 273 await DataStore.StoreAsync<TokenResponse>(userId, token).Configu
reAwait(false); |
286 } | 274 } |
287 } | 275 } |
288 | 276 |
289 /// <summary>Retrieve a new token from the server using the specified re
quest.</summary> | 277 /// <summary>Retrieve a new token from the server using the specified re
quest.</summary> |
290 /// <param name="userId">User identifier</param> | 278 /// <param name="userId">User identifier</param> |
291 /// <param name="request">Token request</param> | 279 /// <param name="request">Token request</param> |
292 /// <param name="taskCancellationToken">Cancellation token to cancel ope
ration</param> | 280 /// <param name="taskCancellationToken">Cancellation token to cancel ope
ration</param> |
293 /// <returns>Token response with the new access token</returns> | 281 /// <returns>Token response with the new access token</returns> |
294 [VisibleForTestOnly] | 282 [VisibleForTestOnly] |
295 internal async Task<TokenResponse> FetchToken(string userId, TokenReques
t request, | 283 internal async Task<TokenResponse> FetchTokenAsync(string userId, TokenR
equest request, |
296 CancellationToken taskCancellationToken) | 284 CancellationToken taskCancellationToken) |
297 { | 285 { |
298 // Add client id and client secret to requests. | 286 // Add client id and client secret to requests. |
299 request.ClientId = ClientSecrets.ClientId; | 287 request.ClientId = ClientSecrets.ClientId; |
300 request.ClientSecret = ClientSecrets.ClientSecret; | 288 request.ClientSecret = ClientSecrets.ClientSecret; |
301 | 289 |
302 var httpRequest = new HttpRequestMessage(HttpMethod.Post, TokenServe
rEncodedUrl); | 290 TokenResponseException tokenException = null; |
303 httpRequest.Content = ParameterUtils.CreateFormUrlEncodedContent(req
uest); | 291 try |
304 | 292 { |
305 var response = await HttpClient.SendAsync(httpRequest, taskCancellat
ionToken).ConfigureAwait(false); | 293 var tokenResponse = await request.Execute(httpClient, TokenServe
rUrl, taskCancellationToken, Clock); |
306 | 294 return tokenResponse; |
307 taskCancellationToken.ThrowIfCancellationRequested(); | 295 } |
308 | 296 catch (TokenResponseException ex) |
309 if (!response.IsSuccessStatusCode) | 297 { |
310 { | 298 // In case there is an exception during getting the token, we de
lete any user's token information from· |
311 var error = NewtonsoftJsonSerializer.Instance.Deserialize<TokenE
rrorResponse>( | 299 // the data store. |
312 await response.Content.ReadAsStringAsync().ConfigureAwait(fa
lse)); | 300 tokenException = ex; |
313 | 301 } |
314 await DeleteToken(userId, taskCancellationToken); | 302 await DeleteTokenAsync(userId, taskCancellationToken); |
315 | 303 throw tokenException; |
316 throw new TokenResponseException(error); | |
317 } | |
318 | |
319 // Get the token and sets its issued time. | |
320 var token = NewtonsoftJsonSerializer.Instance.Deserialize<TokenRespo
nse>( | |
321 await response.Content.ReadAsStringAsync().ConfigureAwait(false)
); | |
322 token.Issued = clock.Now; | |
323 | |
324 return token; | |
325 } | 304 } |
326 | 305 |
327 public void Dispose() | 306 public void Dispose() |
328 { | 307 { |
329 if (HttpClient != null) | 308 if (HttpClient != null) |
330 { | 309 { |
331 HttpClient.Dispose(); | 310 HttpClient.Dispose(); |
332 } | 311 } |
333 } | 312 } |
334 } | 313 } |
335 } | 314 } |
LEFT | RIGHT |