Archived
1
0
Fork 0
This repository has been archived on 2024-05-21. You can view files and clone it, but cannot push or open issues or pull requests.
maki/Maki/Rest/WebRequest.cs

499 lines
16 KiB
C#

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Maki.Rest
{
public class WebRequest : IDisposable
{
private const string USER_AGENT = @"DiscordBot (https://github.com/flashwave/maki, 1.0.0.0)";
private const string GENERIC_CONTENT_TYPE = @"application/octet-stream";
private const string JSON_CONTENT_TYPE = @"application/json";
private const int MAX_RETRIES = 1;
private const int TIMEOUT = 10000;
public int Timeout { get; set; } = TIMEOUT;
public event Action Started;
public event Action Finished;
public event Action<Exception> Failed;
public event Action<long, long> DownloadProgress;
public event Action<long, long> UploadProgress;
public bool IsAborted { get; private set; }
public string Accept { get; set; }
private bool PrivateCompleted;
public bool IsCompleted
{
get => PrivateCompleted;
private set
{
PrivateCompleted = value;
if (!PrivateCompleted)
return;
Started = null;
Finished = null;
DownloadProgress = null;
UploadProgress = null;
}
}
private string PrivateUrl;
private string Url
{
get => PrivateUrl;
set
{
if (!value.StartsWith(@"http://") && !value.StartsWith(@"https://"))
value = RestEndpoints.BASE_URL + RestEndpoints.BASE_PATH + value;
PrivateUrl = value;
}
}
private const int BUFFER_SIZE = 4096;
private byte[] Buffer;
private static HttpClient HttpClient;
public readonly HttpMethod Method;
private bool HasBody => Method == HttpMethod.PUT || Method == HttpMethod.POST || Method == HttpMethod.PATCH;
public string ContentType { get; set; } = GENERIC_CONTENT_TYPE;
[Obsolete]
public long ContentLength => Response.Content.Headers.ContentLength ?? BytesDownloaded;
[Obsolete]
internal static string Authorisation { get; set; }
private readonly Dictionary<string, string> Headers = new Dictionary<string, string>();
private readonly Dictionary<string, string> Parameters = new Dictionary<string, string>();
private readonly Dictionary<string, byte[]> Files = new Dictionary<string, byte[]>();
private long BytesUploaded = 0;
private long BytesDownloaded = 0;
private HttpResponseMessage Response;
private MemoryStream RawRequestBody;
private Stream RequestStream;
private Stream ResponseStream;
private CancellationTokenSource AbortToken;
private CancellationTokenSource TimeoutToken;
public int Retries { get; private set; } = 0;
private byte[] PrivateResponseBytes;
public byte[] ResponseBytes
{
get
{
if (PrivateResponseBytes == null)
using (MemoryStream ms = new MemoryStream())
{
byte[] bytes = new byte[4096];
int read = 0;
while ((read = ResponseStream.Read(bytes, 0, bytes.Length)) > 0)
ms.Write(bytes, 0, read);
ms.Seek(0, SeekOrigin.Begin);
PrivateResponseBytes = new byte[ms.Length];
ms.Read(PrivateResponseBytes, 0, PrivateResponseBytes.Length);
}
return PrivateResponseBytes;
}
}
private string PrivateResponseString = string.Empty;
public string ResponseString
{
get
{
if (string.IsNullOrEmpty(PrivateResponseString))
PrivateResponseString = Encoding.UTF8.GetString(ResponseBytes);
return PrivateResponseString;
}
}
public T ResponseJson<T>() =>
JsonConvert.DeserializeObject<T>(ResponseString);
[Obsolete]
public short Status => (short)Response?.StatusCode;
static WebRequest()
=> CreateHttpClientInstance();
public WebRequest(HttpMethod method, string url)
{
Method = method;
Url = url;
}
private static void CreateHttpClientInstance()
{
HttpClient?.Dispose();
HttpClient = new HttpClient(new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
});
HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(USER_AGENT);
HttpClient.DefaultRequestHeaders.ExpectContinue = true;
HttpClient.Timeout = new TimeSpan(0, 0, 0, 0, System.Threading.Timeout.Infinite);
}
public void AddRaw(Stream stream)
{
if (stream == null)
throw new ArgumentNullException(nameof(stream));
RawRequestBody?.Dispose();
RawRequestBody = new MemoryStream();
stream.CopyTo(RawRequestBody);
}
public void AddRaw(byte[] bytes)
{
using (MemoryStream ms = new MemoryStream(bytes))
AddRaw(ms);
}
public void AddRaw(string str) =>
AddRaw(Encoding.UTF8.GetBytes(str));
public void AddJson(object obj)
{
ContentType = JSON_CONTENT_TYPE;
AddRaw(JsonConvert.SerializeObject(obj));
}
public void AddParam(string name, string contents) =>
Parameters.Add(name, contents);
public void AddFile(string name, byte[] bytes) =>
Files.Add(name, bytes);
public void AddHeader(string name, string value)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentNullException(nameof(name));
if (value == null)
throw new ArgumentNullException(nameof(value));
if (Headers.ContainsKey(name))
Headers[name] = value;
else
Headers.Add(name, value);
}
public void Abort()
{
IsAborted = true;
IsCompleted = true;
try
{
AbortToken?.Cancel();
}
catch (ObjectDisposedException)
{
// just do nothign in this case
}
}
private System.Net.Http.HttpMethod FromInternalHttpMethod(HttpMethod method)
{
switch (method)
{
case HttpMethod.GET:
return System.Net.Http.HttpMethod.Get;
case HttpMethod.DELETE:
return System.Net.Http.HttpMethod.Delete;
case HttpMethod.POST:
return System.Net.Http.HttpMethod.Post;
case HttpMethod.PATCH:
return new System.Net.Http.HttpMethod(@"PATCH");
case HttpMethod.PUT:
return System.Net.Http.HttpMethod.Put;
}
throw new InvalidOperationException($"Unsupported HTTP method {method}.");
}
private void PrivatePerform()
{
using (AbortToken = new CancellationTokenSource())
using (TimeoutToken = new CancellationTokenSource())
using (CancellationTokenSource linkedToken = CancellationTokenSource.CreateLinkedTokenSource(AbortToken.Token, TimeoutToken.Token))
{
try
{
string requestUri = Url;
HttpRequestMessage request = new HttpRequestMessage(FromInternalHttpMethod(Method), requestUri);
foreach (KeyValuePair<string, string> h in Headers)
request.Headers.Add(h.Key, h.Value);
if (!string.IsNullOrEmpty(Accept))
request.Headers.Accept.TryParseAdd(Accept);
if (HasBody)
{
Stream bodyContent;
if (RawRequestBody == null)
{
MultipartFormDataContent formData = new MultipartFormDataContent();
foreach (KeyValuePair<string, string> p in Parameters)
formData.Add(new StringContent(p.Value), p.Key);
foreach (KeyValuePair<string, byte[]> f in Files)
{
ByteArrayContent bac = new ByteArrayContent(f.Value);
bac.Headers.Add("Content-Type", GENERIC_CONTENT_TYPE);
formData.Add(bac, f.Key, f.Key);
}
bodyContent = formData.ReadAsStreamAsync().Result;
} else
{
if (Parameters.Count > 0 || Files.Count > 0)
throw new InvalidOperationException($"You cannot use {nameof(AddRaw)} at the same time as {nameof(AddParam)} or {nameof(AddFile)}");
bodyContent = new MemoryStream();
RawRequestBody.Seek(0, SeekOrigin.Begin);
RawRequestBody.CopyTo(bodyContent);
bodyContent.Seek(0, SeekOrigin.Begin);
}
request.Content = new StreamContent(RequestStream);
if (!string.IsNullOrEmpty(ContentType))
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType);
}
else
{
if (Parameters.Count > 1)
{
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.Append(Url);
if (!Url.Contains('?'))
urlBuilder.Append('?');
foreach (KeyValuePair<string, string> param in Parameters)
{
urlBuilder.Append(param.Key);
urlBuilder.Append('=');
urlBuilder.Append(param.Value);
urlBuilder.Append('&');
}
urlBuilder.Length -= 1;
requestUri = urlBuilder.ToString();
}
}
ReportProgress();
using (request)
Response = HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedToken.Token).Result;
ResponseStream = new MemoryStream();
if (HasBody)
{
ReportProgress();
UploadProgress?.Invoke(0, BytesUploaded);
}
HandleResponse(linkedToken.Token);
} catch (Exception) when (AbortToken.IsCancellationRequested)
{
Complete(new WebException(string.Format("Request to {0} was aborted by the user.", Url), WebExceptionStatus.RequestCanceled));
} catch (Exception) when (TimeoutToken.IsCancellationRequested)
{
Complete(new WebException(string.Format("Request to {0} timed out after {1:N0} seconds idle (read {2:N0} bytes).", Url, TimeSinceLastAction / 1000, BytesDownloaded), WebExceptionStatus.Timeout));
} catch (Exception ex)
{
if (IsCompleted)
throw;
Complete(ex);
}
}
}
public async Task PerformAsync()
{
if (IsCompleted)
throw new InvalidOperationException($"{nameof(WebRequest)} has already been run, you can't reuse WebRequest objects.");
try
{
await Task.Factory.StartNew(PrivatePerform, TaskCreationOptions.LongRunning);
}
catch (AggregateException ex)
{
if (ex.InnerExceptions.Count != 1)
throw ex;
while (ex.InnerExceptions.Count == 1)
{
AggregateException innerEx = ex.InnerException as AggregateException;
ex = innerEx ?? throw innerEx.InnerException;
}
throw ex;
}
}
public void Perform()
=> PerformAsync().Wait();
private void HandleResponse(CancellationToken cancellationToken)
{
using (Stream responseStream = Response.Content.ReadAsStreamAsync().Result)
{
Started?.Invoke();
Buffer = new byte[BUFFER_SIZE];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
int read = responseStream.Read(Buffer, 0, BUFFER_SIZE);
ReportProgress();
if (read > 0)
{
ResponseStream.Write(Buffer, 0, read);
BytesDownloaded += read;
DownloadProgress?.Invoke(BytesDownloaded, Response.Content.Headers.ContentLength ?? BytesDownloaded);
} else
{
ResponseStream.Seek(0, SeekOrigin.Begin);
break;
}
}
}
}
private void Complete(Exception exception = null)
{
if (IsAborted || IsCompleted)
return;
bool allowRetry = true;
if (exception != null)
{
allowRetry = exception is WebException && (exception as WebException)?.Status == WebExceptionStatus.Timeout;
} else if (!Response.IsSuccessStatusCode)
{
exception = new WebException($@"HTTP {Response.StatusCode}");
switch (Response.StatusCode)
{
case HttpStatusCode.NotFound:
case HttpStatusCode.MethodNotAllowed:
case HttpStatusCode.Forbidden:
case HttpStatusCode.Unauthorized:
allowRetry = false;
break;
}
}
if (exception != null)
if (allowRetry && Retries < MAX_RETRIES && BytesDownloaded < 1)
{
++Retries;
PrivatePerform();
}
try
{
// process
} catch (Exception ex)
{
exception = exception == null ? ex : new AggregateException(exception, ex);
}
IsCompleted = true;
if (exception != null)
{
IsAborted = true;
Failed?.Invoke(exception);
throw exception;
}
Finished?.Invoke();
}
#region Timeout
private long LastReportedAction = 0;
private long TimeSinceLastAction => (DateTime.Now.Ticks - LastReportedAction) / TimeSpan.TicksPerMillisecond;
private void ReportProgress()
{
LastReportedAction = DateTime.Now.Ticks;
TimeoutToken.CancelAfter(Timeout);
}
#endregion
#region Disposal
public bool IsDisposed { get; private set; } = false;
private void Dispose(bool disposing)
{
if (IsDisposed)
return;
IsDisposed = true;
// TODO: reimplement disposal
if (disposing)
GC.SuppressFinalize(this);
}
~WebRequest()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
#endregion
}
}