From 7b29e767898ddc7af82c9e3f8ec810baf4dc4722 Mon Sep 17 00:00:00 2001 From: flashwave Date: Wed, 6 Apr 2022 11:25:49 +0200 Subject: [PATCH] Imported from Sharp Chat repository. --- .gitattributes | 1 + .gitignore | 217 ++++++++++++++ Hamakaze.sln | 22 ++ Hamakaze/Hamakaze.csproj | 7 + Hamakaze/Headers/HttpAcceptEncodingHeader.cs | 26 ++ Hamakaze/Headers/HttpConnectionHeader.cs | 17 ++ Hamakaze/Headers/HttpContentEncodingHeader.cs | 20 ++ Hamakaze/Headers/HttpContentLengthHeader.cs | 30 ++ Hamakaze/Headers/HttpContentTypeHeader.cs | 20 ++ Hamakaze/Headers/HttpCustomHeader.cs | 13 + Hamakaze/Headers/HttpDateHeader.cs | 18 ++ Hamakaze/Headers/HttpHeader.cs | 41 +++ Hamakaze/Headers/HttpHostHeader.cs | 37 +++ Hamakaze/Headers/HttpKeepAliveHeader.cs | 35 +++ Hamakaze/Headers/HttpServerHeader.cs | 14 + Hamakaze/Headers/HttpTeHeader.cs | 26 ++ .../Headers/HttpTransferEncodingHeader.cs | 20 ++ Hamakaze/Headers/HttpUserAgentHeader.cs | 20 ++ Hamakaze/HttpClient.cs | 118 ++++++++ Hamakaze/HttpConnection.cs | 81 ++++++ Hamakaze/HttpConnectionManager.cs | 122 ++++++++ Hamakaze/HttpEncoding.cs | 69 +++++ Hamakaze/HttpException.cs | 40 +++ Hamakaze/HttpMediaType.cs | 159 +++++++++++ Hamakaze/HttpMessage.cs | 46 +++ Hamakaze/HttpRequestMessage.cs | 190 +++++++++++++ Hamakaze/HttpResponseMessage.cs | 265 ++++++++++++++++++ Hamakaze/HttpTask.cs | 189 +++++++++++++ Hamakaze/HttpTaskManager.cs | 41 +++ LICENCE | 25 ++ README.md | 7 + 31 files changed, 1936 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Hamakaze.sln create mode 100644 Hamakaze/Hamakaze.csproj create mode 100644 Hamakaze/Headers/HttpAcceptEncodingHeader.cs create mode 100644 Hamakaze/Headers/HttpConnectionHeader.cs create mode 100644 Hamakaze/Headers/HttpContentEncodingHeader.cs create mode 100644 Hamakaze/Headers/HttpContentLengthHeader.cs create mode 100644 Hamakaze/Headers/HttpContentTypeHeader.cs create mode 100644 Hamakaze/Headers/HttpCustomHeader.cs create mode 100644 Hamakaze/Headers/HttpDateHeader.cs create mode 100644 Hamakaze/Headers/HttpHeader.cs create mode 100644 Hamakaze/Headers/HttpHostHeader.cs create mode 100644 Hamakaze/Headers/HttpKeepAliveHeader.cs create mode 100644 Hamakaze/Headers/HttpServerHeader.cs create mode 100644 Hamakaze/Headers/HttpTeHeader.cs create mode 100644 Hamakaze/Headers/HttpTransferEncodingHeader.cs create mode 100644 Hamakaze/Headers/HttpUserAgentHeader.cs create mode 100644 Hamakaze/HttpClient.cs create mode 100644 Hamakaze/HttpConnection.cs create mode 100644 Hamakaze/HttpConnectionManager.cs create mode 100644 Hamakaze/HttpEncoding.cs create mode 100644 Hamakaze/HttpException.cs create mode 100644 Hamakaze/HttpMediaType.cs create mode 100644 Hamakaze/HttpMessage.cs create mode 100644 Hamakaze/HttpRequestMessage.cs create mode 100644 Hamakaze/HttpResponseMessage.cs create mode 100644 Hamakaze/HttpTask.cs create mode 100644 Hamakaze/HttpTaskManager.cs create mode 100644 LICENCE create mode 100644 README.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d85891 --- /dev/null +++ b/.gitignore @@ -0,0 +1,217 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +SharpChat.Common/version.txt + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# JetBrains Rider cache/options directory +.idea/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml diff --git a/Hamakaze.sln b/Hamakaze.sln new file mode 100644 index 0000000..c7281e0 --- /dev/null +++ b/Hamakaze.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hamakaze", "Hamakaze\Hamakaze.csproj", "{B8B0DBA0-7B91-454A-BE4E-D10F383162CA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B8B0DBA0-7B91-454A-BE4E-D10F383162CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8B0DBA0-7B91-454A-BE4E-D10F383162CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8B0DBA0-7B91-454A-BE4E-D10F383162CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8B0DBA0-7B91-454A-BE4E-D10F383162CA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Hamakaze/Hamakaze.csproj b/Hamakaze/Hamakaze.csproj new file mode 100644 index 0000000..f208d30 --- /dev/null +++ b/Hamakaze/Hamakaze.csproj @@ -0,0 +1,7 @@ + + + + net5.0 + + + diff --git a/Hamakaze/Headers/HttpAcceptEncodingHeader.cs b/Hamakaze/Headers/HttpAcceptEncodingHeader.cs new file mode 100644 index 0000000..47b60a7 --- /dev/null +++ b/Hamakaze/Headers/HttpAcceptEncodingHeader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Hamakaze.Headers { + public class HttpAcceptEncodingHeader : HttpHeader { + public const string NAME = @"Accept-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public HttpEncoding[] Encodings { get; } + + public HttpAcceptEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpAcceptEncodingHeader(string[] encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse) + ) {} + + public HttpAcceptEncodingHeader(IEnumerable encodings) { + Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray(); + } + } +} diff --git a/Hamakaze/Headers/HttpConnectionHeader.cs b/Hamakaze/Headers/HttpConnectionHeader.cs new file mode 100644 index 0000000..50b1318 --- /dev/null +++ b/Hamakaze/Headers/HttpConnectionHeader.cs @@ -0,0 +1,17 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpConnectionHeader : HttpHeader { + public const string NAME = @"Connection"; + + public override string Name => NAME; + public override object Value { get; } + + public const string CLOSE = @"close"; + public const string KEEP_ALIVE = @"keep-alive"; + + public HttpConnectionHeader(string mode) { + Value = mode ?? throw new ArgumentNullException(nameof(mode)); + } + } +} diff --git a/Hamakaze/Headers/HttpContentEncodingHeader.cs b/Hamakaze/Headers/HttpContentEncodingHeader.cs new file mode 100644 index 0000000..9972819 --- /dev/null +++ b/Hamakaze/Headers/HttpContentEncodingHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpContentEncodingHeader : HttpHeader { + public const string NAME = @"Content-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public string[] Encodings { get; } + + public HttpContentEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpContentEncodingHeader(string[] encodings) { + Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings)); + } + } +} diff --git a/Hamakaze/Headers/HttpContentLengthHeader.cs b/Hamakaze/Headers/HttpContentLengthHeader.cs new file mode 100644 index 0000000..53f2d31 --- /dev/null +++ b/Hamakaze/Headers/HttpContentLengthHeader.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +namespace Hamakaze.Headers { + public class HttpContentLengthHeader : HttpHeader { + public const string NAME = @"Content-Length"; + + public override string Name => NAME; + public override object Value => Stream?.Length ?? Length; + + private Stream Stream { get; } + private long Length { get; } + + public HttpContentLengthHeader(Stream stream) { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + if(!stream.CanRead || !stream.CanSeek) + throw new ArgumentException(@"Body must readable and seekable.", nameof(stream)); + } + + public HttpContentLengthHeader(long length) { + Length = length; + } + + public HttpContentLengthHeader(string length) { + if(!long.TryParse(length, out long ll)) + throw new ArgumentException(@"Invalid length value.", nameof(length)); + Length = ll; + } + } +} diff --git a/Hamakaze/Headers/HttpContentTypeHeader.cs b/Hamakaze/Headers/HttpContentTypeHeader.cs new file mode 100644 index 0000000..8ef6846 --- /dev/null +++ b/Hamakaze/Headers/HttpContentTypeHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpContentTypeHeader : HttpHeader { + public const string NAME = @"Content-Type"; + + public override string Name => NAME; + public override object Value => MediaType.ToString(); + + public HttpMediaType MediaType { get; } + + public HttpContentTypeHeader(string mediaType) { + MediaType = HttpMediaType.Parse(mediaType ?? throw new ArgumentNullException(nameof(mediaType))); + } + + public HttpContentTypeHeader(HttpMediaType mediaType) { + MediaType = mediaType; + } + } +} diff --git a/Hamakaze/Headers/HttpCustomHeader.cs b/Hamakaze/Headers/HttpCustomHeader.cs new file mode 100644 index 0000000..bfd7196 --- /dev/null +++ b/Hamakaze/Headers/HttpCustomHeader.cs @@ -0,0 +1,13 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpCustomHeader : HttpHeader { + public override string Name { get; } + public override object Value { get; } + + public HttpCustomHeader(string name, object value) { + Name = NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + Value = value; + } + } +} diff --git a/Hamakaze/Headers/HttpDateHeader.cs b/Hamakaze/Headers/HttpDateHeader.cs new file mode 100644 index 0000000..91c36fe --- /dev/null +++ b/Hamakaze/Headers/HttpDateHeader.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; + +namespace Hamakaze.Headers { + public class HttpDateHeader : HttpHeader { + public const string NAME = @"Date"; + + public override string Name => NAME; + public override object Value { get; } + + public DateTimeOffset DateTime { get; } + + public HttpDateHeader(string dateString) { + Value = dateString ?? throw new ArgumentNullException(nameof(dateString)); + DateTime = DateTimeOffset.ParseExact(dateString, @"r", CultureInfo.InvariantCulture); + } + } +} diff --git a/Hamakaze/Headers/HttpHeader.cs b/Hamakaze/Headers/HttpHeader.cs new file mode 100644 index 0000000..b9f75fa --- /dev/null +++ b/Hamakaze/Headers/HttpHeader.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; + +namespace Hamakaze.Headers { + public abstract class HttpHeader { + public abstract string Name { get; } + public abstract object Value { get; } + + public override string ToString() { + return string.Format(@"{0}: {1}", Name, Value); + } + + public static string NormaliseName(string name) { + if(string.IsNullOrWhiteSpace(name)) + return string.Empty; + + string[] parts = name.ToLowerInvariant().Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for(int i = 0; i < parts.Length; ++i) + parts[i] = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(parts[i]); + return string.Join('-', parts); + } + + public static HttpHeader Create(string name, object value) { + return name switch { + HttpTeHeader.NAME => new HttpTeHeader(value.ToString()), + HttpDateHeader.NAME => new HttpDateHeader(value.ToString()), + HttpHostHeader.NAME => new HttpHostHeader(value.ToString()), + HttpServerHeader.NAME => new HttpServerHeader(value.ToString()), + HttpUserAgentHeader.NAME => new HttpUserAgentHeader(value.ToString()), + HttpKeepAliveHeader.NAME => new HttpKeepAliveHeader(value.ToString()), + HttpConnectionHeader.NAME => new HttpConnectionHeader(value.ToString()), + HttpContentTypeHeader.NAME => new HttpContentTypeHeader(value.ToString()), + HttpContentLengthHeader.NAME => new HttpContentLengthHeader(value.ToString()), + HttpAcceptEncodingHeader.NAME => new HttpAcceptEncodingHeader(value.ToString()), + HttpContentEncodingHeader.NAME => new HttpContentEncodingHeader(value.ToString()), + HttpTransferEncodingHeader.NAME => new HttpTransferEncodingHeader(value.ToString()), + _ => new HttpCustomHeader(name, value), + }; + } + } +} diff --git a/Hamakaze/Headers/HttpHostHeader.cs b/Hamakaze/Headers/HttpHostHeader.cs new file mode 100644 index 0000000..c263ff9 --- /dev/null +++ b/Hamakaze/Headers/HttpHostHeader.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Text; + +namespace Hamakaze.Headers { + public class HttpHostHeader : HttpHeader { + public const string NAME = @"Host"; + + public override string Name => NAME; + public override object Value { + get { + StringBuilder sb = new(); + sb.Append(Host); + if(Port != -1) + sb.AppendFormat(@":{0}", Port); + return sb.ToString(); + } + } + + public string Host { get; } + public int Port { get; } + public bool IsSecure { get; } + + public HttpHostHeader(string host, int port) { + Host = host; + Port = port; + } + + public HttpHostHeader(string hostAndPort) { + string[] parts = hostAndPort.Split(':', 2, StringSplitOptions.TrimEntries); + Host = parts.ElementAtOrDefault(0) ?? throw new ArgumentNullException(nameof(hostAndPort)); + if(!ushort.TryParse(parts.ElementAtOrDefault(1), out ushort port)) + throw new FormatException(@"Host is not in valid format."); + Port = port; + } + } +} diff --git a/Hamakaze/Headers/HttpKeepAliveHeader.cs b/Hamakaze/Headers/HttpKeepAliveHeader.cs new file mode 100644 index 0000000..d8ab2c3 --- /dev/null +++ b/Hamakaze/Headers/HttpKeepAliveHeader.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace Hamakaze.Headers { + public class HttpKeepAliveHeader : HttpHeader { + public const string NAME = @"Keep-Alive"; + + public override string Name => NAME; + public override object Value { + get { + List parts = new(); + if(MaxIdle != TimeSpan.MaxValue) + parts.Add(string.Format(@"timeout={0}", MaxIdle.TotalSeconds)); + if(MaxRequests >= 0) + parts.Add(string.Format(@"max={0}", MaxRequests)); + return string.Join(@", ", parts); + } + } + + public TimeSpan MaxIdle { get; } = TimeSpan.MaxValue; + public int MaxRequests { get; } = -1; + + public HttpKeepAliveHeader(string value) { + IEnumerable kvps = (value ?? throw new ArgumentNullException(nameof(value))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach(string kvp in kvps) { + string[] parts = kvp.Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if(parts[0] == @"timeout" && int.TryParse(parts[1], out int timeout)) + MaxIdle = TimeSpan.FromSeconds(timeout); + else if(parts[0] == @"max" && int.TryParse(parts[1], out int max)) + MaxRequests = max; + } + } + } +} diff --git a/Hamakaze/Headers/HttpServerHeader.cs b/Hamakaze/Headers/HttpServerHeader.cs new file mode 100644 index 0000000..c2d665f --- /dev/null +++ b/Hamakaze/Headers/HttpServerHeader.cs @@ -0,0 +1,14 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpServerHeader : HttpHeader { + public const string NAME = @"Server"; + + public override string Name => NAME; + public override object Value { get; } + + public HttpServerHeader(string server) { + Value = server ?? throw new ArgumentNullException(nameof(server)); + } + } +} diff --git a/Hamakaze/Headers/HttpTeHeader.cs b/Hamakaze/Headers/HttpTeHeader.cs new file mode 100644 index 0000000..0ccc4c3 --- /dev/null +++ b/Hamakaze/Headers/HttpTeHeader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Hamakaze.Headers { + public class HttpTeHeader : HttpHeader { + public const string NAME = @"TE"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public HttpEncoding[] Encodings { get; } + + public HttpTeHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpTeHeader(string[] encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse) + ) { } + + public HttpTeHeader(IEnumerable encodings) { + Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray(); + } + } +} diff --git a/Hamakaze/Headers/HttpTransferEncodingHeader.cs b/Hamakaze/Headers/HttpTransferEncodingHeader.cs new file mode 100644 index 0000000..a62939a --- /dev/null +++ b/Hamakaze/Headers/HttpTransferEncodingHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpTransferEncodingHeader : HttpHeader { + public const string NAME = @"Transfer-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public string[] Encodings { get; } + + public HttpTransferEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) {} + + public HttpTransferEncodingHeader(string[] encodings) { + Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings)); + } + } +} diff --git a/Hamakaze/Headers/HttpUserAgentHeader.cs b/Hamakaze/Headers/HttpUserAgentHeader.cs new file mode 100644 index 0000000..b8aa5ec --- /dev/null +++ b/Hamakaze/Headers/HttpUserAgentHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpUserAgentHeader : HttpHeader { + public const string NAME = @"User-Agent"; + + public override string Name => NAME; + public override object Value { get; } + + public HttpUserAgentHeader(string userAgent) { + if(userAgent == null) + throw new ArgumentNullException(nameof(userAgent)); + + if(string.IsNullOrWhiteSpace(userAgent) || userAgent.Equals(HttpClient.USER_AGENT)) + Value = HttpClient.USER_AGENT; + else + Value = string.Format(@"{0} {1}", userAgent, HttpClient.USER_AGENT); + } + } +} diff --git a/Hamakaze/HttpClient.cs b/Hamakaze/HttpClient.cs new file mode 100644 index 0000000..be009b5 --- /dev/null +++ b/Hamakaze/HttpClient.cs @@ -0,0 +1,118 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; + +namespace Hamakaze { + public class HttpClient : IDisposable { + public const string PRODUCT_STRING = @"HMKZ"; + public const string VERSION_MAJOR = @"1"; + public const string VERSION_MINOR = @"0"; + public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR; + + private static HttpClient InstanceValue { get; set; } + public static HttpClient Instance { + get { + if(InstanceValue == null) + InstanceValue = new HttpClient(); + return InstanceValue; + } + } + + private HttpConnectionManager Connections { get; } + private HttpTaskManager Tasks { get; } + + public string DefaultUserAgent { get; set; } = USER_AGENT; + public bool ReuseConnections { get; set; } = true; + public IEnumerable AcceptedEncodings { get; set; } = new[] { HttpEncoding.GZip, HttpEncoding.Deflate, HttpEncoding.Brotli }; + + public HttpClient() { + Connections = new HttpConnectionManager(); + Tasks = new HttpTaskManager(); + } + + public HttpTask CreateTask( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + if(request == null) + throw new ArgumentNullException(nameof(request)); + if(string.IsNullOrWhiteSpace(request.UserAgent)) + request.UserAgent = DefaultUserAgent; + if(!request.HasHeader(HttpAcceptEncodingHeader.NAME)) + request.AcceptedEncodings = AcceptedEncodings; + request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE; + + HttpTask task = new(Connections, request, disposeRequest, disposeResponse); + + if(onComplete != null) + task.OnComplete += onComplete; + if(onError != null) + task.OnError += onError; + if(onCancel != null) + task.OnCancel += onCancel; + if(onDownloadProgress != null) + task.OnDownloadProgress += onDownloadProgress; + if(onUploadProgress != null) + task.OnUploadProgress += onUploadProgress; + if(onStateChange != null) + task.OnStateChange += onStateChange; + + return task; + } + + public void RunTask(HttpTask task) { + Tasks.RunTask(task); + } + + public void SendRequest( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse)); + } + + public static void Send( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse); + } + + private bool IsDisposed; + ~HttpClient() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Tasks.Dispose(); + Connections.Dispose(); + } + } +} diff --git a/Hamakaze/HttpConnection.cs b/Hamakaze/HttpConnection.cs new file mode 100644 index 0000000..8bb90d0 --- /dev/null +++ b/Hamakaze/HttpConnection.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Threading; + +namespace Hamakaze { + public class HttpConnection : IDisposable { + public IPEndPoint EndPoint { get; } + public Stream Stream { get; } + + public Socket Socket { get; } + public NetworkStream NetworkStream { get; } + public SslStream SslStream { get; } + + public string Host { get; } + public bool IsSecure { get; } + + public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle; + + public int MaxRequests { get; set; } = -1; + public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue; + public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now; + + public bool InUse { get; private set; } + + public HttpConnection(string host, IPEndPoint endPoint, bool secure) { + Host = host ?? throw new ArgumentNullException(nameof(host)); + EndPoint = endPoint ?? throw new ArgumentNullException(nameof(endPoint)); + IsSecure = secure; + + if(endPoint.AddressFamily is not AddressFamily.InterNetwork and not AddressFamily.InterNetworkV6) + throw new ArgumentException(@"Address must be an IPv4 or IPv6 address.", nameof(endPoint)); + + Socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { + NoDelay = true, + Blocking = true, + }; + Socket.Connect(endPoint); + + NetworkStream = new NetworkStream(Socket, true); + + if(IsSecure) { + SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null); + Stream = SslStream; + SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true); + } else + Stream = NetworkStream; + } + + public void MarkUsed() { + LastOperation = DateTimeOffset.Now; + if(MaxRequests > 0) + --MaxRequests; + } + + public bool Acquire() { + return !InUse && (InUse = true); + } + + public void Release() { + InUse = false; + } + + private bool IsDisposed; + ~HttpConnection() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Stream.Dispose(); + } + } +} diff --git a/Hamakaze/HttpConnectionManager.cs b/Hamakaze/HttpConnectionManager.cs new file mode 100644 index 0000000..a0c5853 --- /dev/null +++ b/Hamakaze/HttpConnectionManager.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; + +namespace Hamakaze { + public class HttpConnectionManager : IDisposable { + private List Connections { get; } = new(); + private Mutex Lock { get; } = new(); + + public HttpConnectionManager() { + } + + private void AcquireLock() { + if(!Lock.WaitOne(10000)) + throw new HttpConnectionManagerLockException(); + } + + private void ReleaseLock() { + Lock.ReleaseMutex(); + } + + public HttpConnection CreateConnection(string host, IPEndPoint endPoint, bool secure) { + if(host == null) + throw new ArgumentNullException(nameof(host)); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + HttpConnection conn = null; + AcquireLock(); + try { + conn = CreateConnectionInternal(host, endPoint, secure); + } finally { + ReleaseLock(); + } + return conn; + } + + private HttpConnection CreateConnectionInternal(string host, IPEndPoint endPoint, bool secure) { + HttpConnection conn = new(host, endPoint, secure); + Connections.Add(conn); + return conn; + } + + public HttpConnection GetConnection(string host, IPEndPoint endPoint, bool secure) { + if(host == null) + throw new ArgumentNullException(nameof(host)); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + HttpConnection conn = null; + AcquireLock(); + try { + conn = GetConnectionInternal(host, endPoint, secure); + } finally { + ReleaseLock(); + } + return conn; + } + + private HttpConnection GetConnectionInternal(string host, IPEndPoint endPoint, bool secure) { + CleanConnectionsInternal(); + HttpConnection conn = Connections.FirstOrDefault(c => host.Equals(c.Host) && endPoint.Equals(c.EndPoint) && c.IsSecure == secure && c.Acquire()); + if(conn == null) { + conn = CreateConnectionInternal(host, endPoint, secure); + conn.Acquire(); + } + return conn; + } + + public void EndConnection(HttpConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + AcquireLock(); + try { + EndConnectionInternal(conn); + } finally { + ReleaseLock(); + } + } + + private void EndConnectionInternal(HttpConnection conn) { + Connections.Remove(conn); + conn.Dispose(); + } + + public void CleanConnection() { + AcquireLock(); + try { + CleanConnectionsInternal(); + } finally { + ReleaseLock(); + } + } + + private void CleanConnectionsInternal() { + IEnumerable conns = Connections.Where(x => x.HasTimedOut).ToArray(); + foreach(HttpConnection conn in conns) { + Connections.Remove(conn); + conn.Dispose(); + } + } + + private bool IsDisposed; + ~HttpConnectionManager() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Lock.Dispose(); + + foreach(HttpConnection conn in Connections) + conn.Dispose(); + Connections.Clear(); + } + } +} diff --git a/Hamakaze/HttpEncoding.cs b/Hamakaze/HttpEncoding.cs new file mode 100644 index 0000000..5b6e3a5 --- /dev/null +++ b/Hamakaze/HttpEncoding.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Hamakaze { + public readonly struct HttpEncoding : IComparable, IEquatable { + public const string DEFLATE = @"deflate"; + public const string GZIP = @"gzip"; + public const string XGZIP = @"x-gzip"; + public const string BROTLI = @"br"; + public const string IDENTITY = @"identity"; + public const string CHUNKED = @"chunked"; + public const string ANY = @"*"; + + public static readonly HttpEncoding Any = new(ANY); + public static readonly HttpEncoding None = new(ANY, 0f); + public static readonly HttpEncoding Deflate = new(DEFLATE); + public static readonly HttpEncoding GZip = new(GZIP); + public static readonly HttpEncoding Brotli = new(BROTLI); + public static readonly HttpEncoding Identity = new(IDENTITY); + + public string Name { get; } + public float Quality { get; } + + public HttpEncoding(string name, float quality = 1f) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Quality = quality; + } + + public HttpEncoding WithQuality(float quality) { + return new HttpEncoding(Name, quality); + } + + public static HttpEncoding Parse(string encoding) { + string[] parts = encoding.Split(';', StringSplitOptions.TrimEntries); + float quality = 1f; + encoding = parts[0]; + + for(int i = 1; i < parts.Length; ++i) + if(parts[i].StartsWith(@"q=")) { + if(!float.TryParse(parts[i], out quality)) + quality = 1f; + break; + } + + return new HttpEncoding(encoding, quality); + } + + public override string ToString() { + StringBuilder sb = new(); + sb.Append(Name); + if(Quality is >= 0f and < 1f) + sb.AppendFormat(CultureInfo.InvariantCulture, @";q={0:0.0}", Quality); + return sb.ToString(); + } + + public int CompareTo(HttpEncoding? other) { + if(!other.HasValue || other.Value.Quality < Quality) + return -1; + if(other.Value.Quality > Quality) + return 1; + return 0; + } + + public bool Equals(HttpEncoding? other) { + return other.HasValue && Name.Equals(other.Value.Name) && Quality.Equals(other.Value.Quality); + } + } +} diff --git a/Hamakaze/HttpException.cs b/Hamakaze/HttpException.cs new file mode 100644 index 0000000..d6e0bce --- /dev/null +++ b/Hamakaze/HttpException.cs @@ -0,0 +1,40 @@ +using System; + +namespace Hamakaze { + public class HttpException : Exception { + public HttpException(string message) : base(message) { } + } + + public class HttpConnectionManagerException : HttpException { + public HttpConnectionManagerException(string message) : base(message) { } + } + public class HttpConnectionManagerLockException : HttpConnectionManagerException { + public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { } + } + + public class HttpTaskException : HttpException { + public HttpTaskException(string message) : base(message) { } + } + public class HttpTaskAlreadyStartedException : HttpTaskException { + public HttpTaskAlreadyStartedException() : base(@"Task has already started.") { } + } + public class HttpTaskInvalidStateException : HttpTaskException { + public HttpTaskInvalidStateException() : base(@"Task has ended up in an invalid state.") { } + } + public class HttpTaskNoAddressesException : HttpTaskException { + public HttpTaskNoAddressesException() : base(@"Could not find any addresses for this host.") { } + } + public class HttpTaskNoConnectionException : HttpTaskException { + public HttpTaskNoConnectionException() : base(@"Was unable to create a connection with this host.") { } + } + public class HttpTaskRequestFailedException : HttpTaskException { + public HttpTaskRequestFailedException() : base(@"Request failed for unknown reasons.") { } + } + + public class HttpTaskManagerException : HttpException { + public HttpTaskManagerException(string message) : base(message) { } + } + public class HttpTaskManagerLockException : HttpTaskManagerException { + public HttpTaskManagerLockException() : base(@"Failed to reserve a thread.") { } + } +} diff --git a/Hamakaze/HttpMediaType.cs b/Hamakaze/HttpMediaType.cs new file mode 100644 index 0000000..e3ca1e6 --- /dev/null +++ b/Hamakaze/HttpMediaType.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public readonly struct HttpMediaType : IComparable, IEquatable { + public const string TYPE_APPLICATION = @"application"; + public const string TYPE_AUDIO = @"audio"; + public const string TYPE_IMAGE = @"image"; + public const string TYPE_MESSAGE = @"message"; + public const string TYPE_MULTIPART = @"multipart"; + public const string TYPE_TEXT = @"text"; + public const string TYPE_VIDEO = @"video"; + + public static readonly HttpMediaType OctetStream = new(TYPE_APPLICATION, @"octet-stream"); + public static readonly HttpMediaType FWIF = new(TYPE_APPLICATION, @"x.fwif"); + public static readonly HttpMediaType JSON = new(TYPE_APPLICATION, @"json"); + public static readonly HttpMediaType HTML = new(TYPE_TEXT, @"html", args: new[] { Param.UTF8 }); + + public string Type { get; } + public string Subtype { get; } + public string Suffix { get; } + public IEnumerable Params { get; } + + public HttpMediaType(string type, string subtype, string suffix = null, IEnumerable args = null) { + Type = type ?? throw new ArgumentNullException(nameof(type)); + Subtype = subtype ?? throw new ArgumentNullException(nameof(subtype)); + Suffix = suffix ?? string.Empty; + Params = args ?? Enumerable.Empty(); + } + + public string GetParamValue(string name) { + foreach(Param param in Params) + if(param.Name.ToLowerInvariant() == name) + return param.Value; + return null; + } + + public static explicit operator HttpMediaType(string mediaTypeString) => Parse(mediaTypeString); + + public static HttpMediaType Parse(string mediaTypeString) { + if(mediaTypeString == null) + throw new ArgumentNullException(nameof(mediaTypeString)); + + int slashIndex = mediaTypeString.IndexOf('/'); + if(slashIndex == -1) + return OctetStream; + + string type = mediaTypeString[..slashIndex]; + string subtype = mediaTypeString[(slashIndex + 1)..]; + string suffix = null; + IEnumerable args = null; + + int paramIndex = subtype.IndexOf(';'); + if(paramIndex != -1) { + args = subtype[(paramIndex + 1)..] + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Param.Parse); + subtype = subtype[..paramIndex]; + } + + int suffixIndex = subtype.IndexOf('+'); + if(suffixIndex != -1) { + suffix = subtype[(suffixIndex + 1)..]; + subtype = subtype[..suffixIndex]; + } + + return new HttpMediaType(type, subtype, suffix, args); + } + + public override string ToString() { + StringBuilder sb = new(); + sb.AppendFormat(@"{0}/{1}", Type, Subtype); + if(!string.IsNullOrWhiteSpace(Suffix)) + sb.AppendFormat(@"+{0}", Suffix); + if(Params.Any()) + sb.AppendFormat(@";{0}", string.Join(';', Params)); + return sb.ToString(); + } + + public int CompareTo(HttpMediaType? other) { + if(!other.HasValue) + return -1; + int type = Type.CompareTo(other.Value.Type); + if(type != 0) + return type; + int subtype = Subtype.CompareTo(other.Value.Subtype); + if(subtype != 0) + return subtype; + int suffix = Suffix.CompareTo(other.Value.Suffix); + if(suffix != 0) + return suffix; + int paramCount = Params.Count(); + int args = paramCount - other.Value.Params.Count(); + if(args != 0) + return args; + for(int i = 0; i < paramCount; ++i) { + args = Params.ElementAt(i).CompareTo(other.Value.Params.ElementAt(i)); + if(args != 0) + return args; + } + return 0; + } + + public bool Equals(HttpMediaType? other) { + if(!other.HasValue) + return false; + if(!Type.Equals(other.Value.Type) || !Subtype.Equals(other.Value.Subtype) || !Suffix.Equals(other.Value.Suffix)) + return false; + int paramCount = Params.Count(); + if(paramCount != other.Value.Params.Count()) + return false; + for(int i = 0; i < paramCount; ++i) + if(!Params.ElementAt(i).Equals(other.Value.Params.ElementAt(i))) + return false; + return true; + } + + public readonly struct Param : IComparable, IEquatable { + public const string CHARSET = @"charset"; + + public static readonly Param ASCII = new(CHARSET, @"us-ascii"); + public static readonly Param UTF8 = new(CHARSET, @"utf-8"); + + public string Name { get; } + public string Value { get; } + + public Param(string name, string value) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Value = value ?? throw new ArgumentNullException(nameof(name)); + } + + public override string ToString() { + return string.Format(@"{0}={1}", Name, Value); + } + + public static explicit operator Param(string paramStr) => Parse(paramStr); + + public static Param Parse(string paramStr) { + string[] parts = (paramStr ?? throw new ArgumentNullException(nameof(paramStr))).Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return new Param(parts[0], parts[1]); + } + + public int CompareTo(Param? other) { + if(!other.HasValue) + return -1; + int name = Name.CompareTo(other.Value.Name); + return name != 0 + ? name + : Value.CompareTo(other.Value.Value); + } + + public bool Equals(Param? other) { + return other.HasValue && Name.Equals(other.Value.Name) && Value.Equals(other.Value.Value); + } + } + } +} diff --git a/Hamakaze/HttpMessage.cs b/Hamakaze/HttpMessage.cs new file mode 100644 index 0000000..cbfc22e --- /dev/null +++ b/Hamakaze/HttpMessage.cs @@ -0,0 +1,46 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Hamakaze { + public abstract class HttpMessage : IDisposable { + public abstract string ProtocolVersion { get; } + public abstract IEnumerable Headers { get; } + public abstract Stream Body { get; } + + public virtual bool HasBody => Body != null; + + protected bool OwnsBodyStream { get; set; } + + public virtual IEnumerable GetHeader(string header) { + header = HttpHeader.NormaliseName(header); + return Headers.Where(h => h.Name == header); + } + + public virtual bool HasHeader(string header) { + header = HttpHeader.NormaliseName(header); + return Headers.Any(h => h.Name == header); + } + + public virtual string GetHeaderLine(string header) { + return string.Join(@", ", GetHeader(header).Select(h => h.Value)); + } + + private bool IsDisposed; + ~HttpMessage() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + protected void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + if(OwnsBodyStream && Body != null) + Body.Dispose(); + } + } +} diff --git a/Hamakaze/HttpRequestMessage.cs b/Hamakaze/HttpRequestMessage.cs new file mode 100644 index 0000000..6ce3ce3 --- /dev/null +++ b/Hamakaze/HttpRequestMessage.cs @@ -0,0 +1,190 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public class HttpRequestMessage : HttpMessage { + public const string GET = @"GET"; + public const string PUT = @"PUT"; + public const string HEAD = @"HEAD"; + public const string POST = @"POST"; + public const string DELETE = @"DELETE"; + + public override string ProtocolVersion => @"1.1"; + + public string Method { get; } + public string RequestTarget { get; } + + public bool IsSecure { get; } + + public string Host { get; } + public ushort Port { get; } + public bool IsDefaultPort { get; } + + public override IEnumerable Headers => HeaderList; + private List HeaderList { get; } = new(); + + private Stream BodyStream { get; set; } + public override Stream Body { + get { + if(BodyStream == null) { + OwnsBodyStream = true; + SetBody(new MemoryStream()); + } + return BodyStream; + } + } + + private static readonly string[] HEADERS_READONLY = new[] { + HttpHostHeader.NAME, HttpContentLengthHeader.NAME, + }; + private static readonly string[] HEADERS_SINGLE = new[] { + HttpUserAgentHeader.NAME, HttpConnectionHeader.NAME, HttpAcceptEncodingHeader.NAME, + }; + + public IEnumerable AcceptedEncodings { + get => HeaderList.Where(x => x.Name == HttpAcceptEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + + set { + HeaderList.RemoveAll(x => x.Name == HttpAcceptEncodingHeader.NAME); + HeaderList.Add(new HttpAcceptEncodingHeader(value)); + } + } + + public string UserAgent { + get => HeaderList.FirstOrDefault(x => x.Name == HttpUserAgentHeader.NAME)?.Value.ToString() + ?? string.Empty; + set { + HeaderList.RemoveAll(x => x.Name == HttpUserAgentHeader.NAME); + HeaderList.Add(new HttpUserAgentHeader(value)); + } + } + + public string Connection { + get => HeaderList.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString() + ?? string.Empty; + set { + HeaderList.RemoveAll(x => x.Name == HttpConnectionHeader.NAME); + HeaderList.Add(new HttpConnectionHeader(value)); + } + } + + public HttpMediaType ContentType { + get => HeaderList.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast().FirstOrDefault()?.MediaType + ?? HttpMediaType.OctetStream; + set { + HeaderList.RemoveAll(x => x.Name == HttpContentTypeHeader.NAME); + HeaderList.Add(new HttpContentTypeHeader(value)); + } + } + + public HttpRequestMessage(string method, string uri) : this( + method, new Uri(uri) + ) {} + + public const ushort HTTP = 80; + public const ushort HTTPS = 443; + + public HttpRequestMessage(string method, Uri uri) { + Method = method ?? throw new ArgumentNullException(nameof(method)); + RequestTarget = uri.PathAndQuery; + IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase); + Host = uri.Host; + ushort defaultPort = (IsSecure ? HTTPS : HTTP); + Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port; + IsDefaultPort = Port == defaultPort; + HeaderList.Add(new HttpHostHeader(Host, IsDefaultPort ? -1 : Port)); + } + + public static bool IsHeaderReadOnly(string name) + => HEADERS_READONLY.Contains(name ?? throw new ArgumentNullException(nameof(name))); + public static bool IsHeaderSingleInstance(string name) + => HEADERS_SINGLE.Contains(name ?? throw new ArgumentNullException(nameof(name))); + + public void SetHeader(string name, object value) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + HeaderList.RemoveAll(x => x.Name == name); + HeaderList.Add(HttpHeader.Create(name, value)); + } + + public void AddHeader(string name, object value) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + if(IsHeaderSingleInstance(name)) + HeaderList.RemoveAll(x => x.Name == name); + HeaderList.Add(HttpHeader.Create(name, value)); + } + + public void RemoveHeader(string name) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + HeaderList.RemoveAll(x => x.Name == name); + } + + public void SetBody(Stream stream) { + if(stream == null) { + if(OwnsBodyStream) + BodyStream?.Dispose(); + OwnsBodyStream = false; + BodyStream = null; + HeaderList.RemoveAll(x => x.Name == HttpContentLengthHeader.NAME); + } else { + if(!stream.CanRead || !stream.CanSeek) + throw new ArgumentException(@"Body must readable and seekable.", nameof(stream)); + if(OwnsBodyStream) + BodyStream?.Dispose(); + OwnsBodyStream = false; + BodyStream = stream; + HeaderList.Add(new HttpContentLengthHeader(BodyStream)); + } + } + + public void SetBody(byte[] buffer) { + SetBody(new MemoryStream(buffer)); + OwnsBodyStream = true; + } + + public void SetBody(string str, Encoding encoding = null) { + SetBody((encoding ?? Encoding.UTF8).GetBytes(str)); + } + + public void WriteTo(Stream stream, Action onProgress = null) { + using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) { + sw.NewLine = "\r\n"; + sw.Write(Method); + sw.Write(' '); + sw.Write(RequestTarget); + sw.Write(@" HTTP/"); + sw.WriteLine(ProtocolVersion); + foreach(HttpHeader header in Headers) + sw.WriteLine(header); + sw.WriteLine(); + sw.Flush(); + } + + if(BodyStream != null) { + const int bufferSize = 8192; + byte[] buffer = new byte[bufferSize]; + int read; + long totalRead = 0; + + onProgress?.Invoke(totalRead, BodyStream.Length); + + BodyStream.Seek(0, SeekOrigin.Begin); + while((read = BodyStream.Read(buffer, 0, bufferSize)) > 0) { + stream.Write(buffer, 0, read); + totalRead += read; + onProgress?.Invoke(totalRead, BodyStream.Length); + } + } + } + } +} diff --git a/Hamakaze/HttpResponseMessage.cs b/Hamakaze/HttpResponseMessage.cs new file mode 100644 index 0000000..c041e93 --- /dev/null +++ b/Hamakaze/HttpResponseMessage.cs @@ -0,0 +1,265 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public class HttpResponseMessage : HttpMessage { + public override string ProtocolVersion { get; } + public int StatusCode { get; } + public string StatusMessage { get; } + + public override IEnumerable Headers { get; } + + public override Stream Body { get; } + + public string Connection + => Headers.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString() ?? string.Empty; + public string Server + => Headers.FirstOrDefault(x => x.Name == HttpServerHeader.NAME)?.Value.ToString() ?? string.Empty; + public DateTimeOffset Date + => Headers.Where(x => x.Name == HttpDateHeader.NAME).Cast().FirstOrDefault()?.DateTime ?? DateTimeOffset.MinValue; + public HttpMediaType ContentType + => Headers.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast().FirstOrDefault()?.MediaType + ?? HttpMediaType.OctetStream; + public Encoding ResponseEncoding + => Encoding.GetEncoding(ContentType.GetParamValue(@"charset") ?? @"iso8859-1"); + public IEnumerable ContentEncodings + => Headers.Where(x => x.Name == HttpContentEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + public IEnumerable TransferEncodings + => Headers.Where(x => x.Name == HttpTransferEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + + public HttpResponseMessage( + int statusCode, string statusMessage, string protocolVersion, + IEnumerable headers, Stream body + ) { + ProtocolVersion = protocolVersion ?? throw new ArgumentNullException(nameof(protocolVersion)); + StatusCode = statusCode; + StatusMessage = statusMessage ?? string.Empty; + Headers = (headers ?? throw new ArgumentNullException(nameof(headers))).ToArray(); + OwnsBodyStream = true; + Body = body; + } + + public byte[] GetBodyBytes() { + if(Body == null) + return null; + if(Body is MemoryStream msBody) + return msBody.ToArray(); + using MemoryStream ms = new(); + if(Body.CanSeek) + Body.Seek(0, SeekOrigin.Begin); + Body.CopyTo(ms); + return ms.ToArray(); + } + + public string GetBodyString() { + byte[] bytes = GetBodyBytes(); + return bytes == null || bytes.Length < 1 + ? string.Empty + : ResponseEncoding.GetString(bytes); + } + + // there's probably a less stupid way to do this, be my guest and call me an idiot + private static void ProcessEncoding(Stack encodings, Stream stream, bool transfer) { + using MemoryStream temp = new(); + bool inTemp = false; + + while(encodings.TryPop(out string encoding)) { + Stream target = (inTemp = !inTemp) ? temp : stream, + source = inTemp ? stream : temp; + + target.SetLength(0); + source.Seek(0, SeekOrigin.Begin); + + switch(encoding) { + case HttpEncoding.GZIP: + case HttpEncoding.XGZIP: + using(GZipStream gzs = new(source, CompressionMode.Decompress, true)) + gzs.CopyTo(target); + break; + + case HttpEncoding.DEFLATE: + using(DeflateStream def = new(source, CompressionMode.Decompress, true)) + def.CopyTo(target); + break; + + case HttpEncoding.BROTLI: + if(transfer) + goto default; + using(BrotliStream br = new(source, CompressionMode.Decompress, true)) + br.CopyTo(target); + break; + + case HttpEncoding.IDENTITY: + break; + + case HttpEncoding.CHUNKED: + if(!transfer) + goto default; + throw new IOException(@"Invalid use of chunked encoding type in Transfer-Encoding header."); + + default: + throw new IOException(@"Unsupported encoding supplied."); + } + } + + if(inTemp) { + stream.SetLength(0); + temp.Seek(0, SeekOrigin.Begin); + temp.CopyTo(stream); + } + } + + public static HttpResponseMessage ReadFrom(Stream stream, Action onProgress = null) { + // ignore this function, it doesn't exist + string readLine() { + const ushort crlf = 0x0D0A; + using MemoryStream ms = new(); + int byt; ushort lastTwo = 0; + + for(; ; ) { + byt = stream.ReadByte(); + if(byt == -1 && ms.Length == 0) + return null; + + ms.WriteByte((byte)byt); + + lastTwo <<= 8; + lastTwo |= (byte)byt; + if(lastTwo == crlf) { + ms.SetLength(ms.Length - 2); + break; + } + } + + return Encoding.ASCII.GetString(ms.ToArray()); + } + + long contentLength = -1; + Stack transferEncodings = null; + Stack contentEncodings = null; + + // Read initial header + string line = readLine(); + if(line == null) + throw new IOException(@"Failed to read initial HTTP header."); + if(!line.StartsWith(@"HTTP/")) + throw new IOException(@"Response is not a valid HTTP message."); + string[] parts = line[5..].Split(' ', 3); + if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode)) + throw new IOException(@"Invalid HTTP status code format."); + string protocolVersion = parts.ElementAtOrDefault(0); + string statusMessage = parts.ElementAtOrDefault(2); + + // Read header key-value pairs + List headers = new(); + + while((line = readLine()) != null) { + if(string.IsNullOrWhiteSpace(line)) + break; + + parts = line.Split(':', 2, StringSplitOptions.TrimEntries); + if(parts.Length < 2) + throw new IOException(@"Invalid HTTP header in response."); + + string hName = HttpHeader.NormaliseName(parts.ElementAtOrDefault(0) ?? string.Empty), + hValue = parts.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(hName)) + throw new IOException(@"Invalid HTTP header name."); + + HttpHeader header = HttpHeader.Create(hName, hValue); + + if(header is HttpContentLengthHeader hclh) + contentLength = (long)hclh.Value; + else if(header is HttpTransferEncodingHeader hteh) + transferEncodings = new Stack(hteh.Encodings); + else if(header is HttpContentEncodingHeader hceh) + contentEncodings = new Stack(hceh.Encodings); + + headers.Add(header); + } + + if(statusCode is < 200 or 201 or 204 or 205) + contentLength = 0; + + Stream body = null; + long totalRead = 0; + const int buffer_size = 8192; + byte[] buffer = new byte[buffer_size]; + int read; + + void readBuffer(long length = -1) { + if(length == 0) + return; + long remaining = length; + int bufferRead = buffer_size; + if(bufferRead > length) + bufferRead = (int)length; + + if(totalRead < 1) + onProgress?.Invoke(0, contentLength); + + while((read = stream.Read(buffer, 0, bufferRead)) > 0) { + body.Write(buffer, 0, read); + + totalRead += read; + onProgress?.Invoke(totalRead, contentLength); + + if(length >= 0) { + remaining -= read; + if(remaining < 1) + break; + if(bufferRead > remaining) + bufferRead = (int)remaining; + } + } + } + + // Read body + if(transferEncodings != null && transferEncodings.Any() && transferEncodings.Peek() == HttpEncoding.CHUNKED) { + // oh no the poop is chunky + transferEncodings.Pop(); + body = new MemoryStream(); + + while((line = readLine()) != null) { + if(string.IsNullOrWhiteSpace(line)) + break; + if(!int.TryParse(line, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int chunkLength)) + throw new IOException(@"Failed to decode chunk length."); + if(chunkLength == 0) // final chunk + break; + readBuffer(chunkLength); + readLine(); + } + readLine(); + } else if(contentLength != 0) { + body = new MemoryStream(); + readBuffer(contentLength); + readLine(); + } + + if(body != null) + // Check if body is empty and null it again if so + if(body.Length == 0) { + body.Dispose(); + body = null; + } else { + if(transferEncodings != null) + ProcessEncoding(transferEncodings, body, true); + if(contentEncodings != null) + ProcessEncoding(contentEncodings, body, false); + + body.Seek(0, SeekOrigin.Begin); + } + + return new HttpResponseMessage(statusCode, statusMessage, protocolVersion, headers, body); + } + } +} diff --git a/Hamakaze/HttpTask.cs b/Hamakaze/HttpTask.cs new file mode 100644 index 0000000..ddcd212 --- /dev/null +++ b/Hamakaze/HttpTask.cs @@ -0,0 +1,189 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; + +namespace Hamakaze { + public class HttpTask { + public TaskState State { get; private set; } = TaskState.Initial; + + public bool IsStarted + => State != TaskState.Initial; + public bool IsFinished + => State == TaskState.Finished; + public bool IsCancelled + => State == TaskState.Cancelled; + public bool IsErrored + => Exception != null; + + public Exception Exception { get; private set; } + + public HttpRequestMessage Request { get; } + public HttpResponseMessage Response { get; private set; } + private HttpConnectionManager Connections { get; } + + private IEnumerable Addresses { get; set; } + private HttpConnection Connection { get; set; } + + public bool DisposeRequest { get; set; } + public bool DisposeResponse { get; set; } + + public event Action OnComplete; + public event Action OnError; + public event Action OnCancel; + public event Action OnUploadProgress; + public event Action OnDownloadProgress; + public event Action OnStateChange; + + public HttpTask(HttpConnectionManager conns, HttpRequestMessage request, bool disposeRequest, bool disposeResponse) { + Connections = conns ?? throw new ArgumentNullException(nameof(conns)); + Request = request ?? throw new ArgumentNullException(nameof(request)); + DisposeRequest = disposeRequest; + DisposeResponse = disposeResponse; + } + + public void Run() { + if(IsStarted) + throw new HttpTaskAlreadyStartedException(); + while(NextStep()); + } + + public void Cancel() { + State = TaskState.Cancelled; + OnStateChange?.Invoke(this, State); + OnCancel?.Invoke(this); + if(DisposeResponse) + Response?.Dispose(); + if(DisposeRequest) + Request?.Dispose(); + } + + private void Error(Exception ex) { + Exception = ex; + OnError?.Invoke(this, ex); + Cancel(); + } + + public bool NextStep() { + if(IsCancelled) + return false; + + switch(State) { + case TaskState.Initial: + State = TaskState.Lookup; + OnStateChange?.Invoke(this, State); + DoLookup(); + break; + case TaskState.Lookup: + State = TaskState.Request; + OnStateChange?.Invoke(this, State); + DoRequest(); + break; + case TaskState.Request: + State = TaskState.Response; + OnStateChange?.Invoke(this, State); + DoResponse(); + break; + case TaskState.Response: + State = TaskState.Finished; + OnStateChange?.Invoke(this, State); + OnComplete?.Invoke(this, Response); + if(DisposeResponse) + Response?.Dispose(); + if(DisposeRequest) + Request?.Dispose(); + return false; + default: + Error(new HttpTaskInvalidStateException()); + return false; + } + + return true; + } + + private void DoLookup() { + try { + Addresses = Dns.GetHostAddresses(Request.Host); + } catch(Exception ex) { + Error(ex); + return; + } + + if(!Addresses.Any()) + Error(new HttpTaskNoAddressesException()); + } + + private void DoRequest() { + Exception exception = null; + + try { + foreach(IPAddress addr in Addresses) { + int tries = 0; + IPEndPoint endPoint = new(addr, Request.Port); + + exception = null; + Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + + retry: + ++tries; + try { + Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t)); + break; + } catch(IOException ex) { + Connection.Dispose(); + Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + + if(tries < 2) + goto retry; + + exception = ex; + continue; + } finally { + Connection.MarkUsed(); + } + } + } catch(Exception ex) { + Error(ex); + } + + if(exception != null) + Error(exception); + else if(Connection == null) + Error(new HttpTaskNoConnectionException()); + } + + private void DoResponse() { + try { + Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t)); + } catch(Exception ex) { + Error(ex); + return; + } + + if(Response.Connection == HttpConnectionHeader.CLOSE) + Connection.Dispose(); + if(Response == null) + Error(new HttpTaskRequestFailedException()); + + HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast().FirstOrDefault(); + if(hkah != null) { + Connection.MaxIdle = hkah.MaxIdle; + Connection.MaxRequests = hkah.MaxRequests; + } + + Connection.Release(); + } + + public enum TaskState { + Initial = 0, + Lookup = 10, + Request = 20, + Response = 30, + Finished = 40, + + Cancelled = -1, + } + } +} diff --git a/Hamakaze/HttpTaskManager.cs b/Hamakaze/HttpTaskManager.cs new file mode 100644 index 0000000..ef12439 --- /dev/null +++ b/Hamakaze/HttpTaskManager.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; + +namespace Hamakaze { + public class HttpTaskManager : IDisposable { + private Semaphore Lock { get; set; } + + public HttpTaskManager(int maxThreads = 5) { + Lock = new Semaphore(maxThreads, maxThreads); + } + + public void RunTask(HttpTask task) { + if(task == null) + throw new ArgumentNullException(nameof(task)); + if(!Lock.WaitOne()) + throw new HttpTaskManagerLockException(); + new Thread(() => { + try { + task.Run(); + } finally { + Lock?.Release(); + } + }).Start(); + } + + private bool IsDisposed; + ~HttpTaskManager() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Lock.Dispose(); + Lock = null; + } + } +} diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..691d03b --- /dev/null +++ b/LICENCE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2021-2022, flashwave +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93a72ab --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Hamakaze + +Shoddy custom HTTP client, currently targeting C#. + +System.Http.HttpClient is annoying cause it forces async on you and they've conveniently deprecated everything else. + +Not really intended for use by anyone other than myself.