Added working ln.http.HttpClient

master
Harald Wolff 2024-04-21 23:25:05 +02:00
parent eb122b944b
commit 35517e431c
15 changed files with 373 additions and 131 deletions

View File

@ -2,9 +2,9 @@
<PropertyGroup>
<Nullable>enable</Nullable>
<TargetFrameworks>net5.0;net6.0</TargetFrameworks>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>0.9.0-test1</PackageVersion>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<LangVersion>9</LangVersion>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<PackageVersion>1.0.1</PackageVersion>
</PropertyGroup>

View File

@ -0,0 +1,37 @@
using System;
using ln.http.client;
using NUnit.Framework;
namespace ln.http.tests
{
public class ClientTests
{
private HttpClient HttpClient;
[SetUp]
public void Setup()
{
HttpClient = new HttpClient();
}
[Test]
public void TestClient()
{
var o = HttpClient.Get("http://l--n.de");
if (o && o.Value is HttpClientResponse response)
{
Assert.AreEqual(HttpStatusCode.Found, response.StatusCode);
Assert.AreEqual("https://l--n.de/", response.Headers.Get("Location"));
}
HttpClient.FollowRedirects = true;
o = HttpClient.Get("http://l--n.de");
if (o && o.Value is HttpClientResponse response2)
{
Assert.AreEqual(HttpStatusCode.OK, response2.StatusCode);
}
}
}
}

View File

@ -6,7 +6,7 @@
<LangVersion>9</LangVersion>
<TargetFrameworks>net5.0;net6.0</TargetFrameworks>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@ -1,3 +1,4 @@
using System;
using System.Security.Cryptography.X509Certificates;
using ln.collections;
@ -13,7 +14,7 @@ public class CertificateStore
public virtual void AddCertificate(X509Certificate certificate) => _cache.Add(certificate.Subject, certificate);
public virtual bool TryGetCertificate(string hostname, out X509Certificate certificate) =>
_cache.TryGetValue(hostname, out certificate);
_cache.TryGetValue(String.Format("CN={0}", hostname), out certificate);
public virtual void RemoveCertificate(string hostname) => _cache.Remove(hostname);
}

View File

@ -14,6 +14,11 @@ namespace ln.http
public HeaderContainer()
{
}
public HeaderContainer(IEnumerable<Header> headers)
{
foreach (var header in headers)
Add(new Header(header));
}
public HeaderContainer(Stream stream)
{
@ -52,6 +57,8 @@ namespace ln.http
_headers.Add(lowerHeaderName, new Header(headerName, headerValue));
}
public void Add(Header header) => Add(header.Name, header.Value);
public void Remove(string headerName) => _headers.Remove(headerName.ToLower());
public void Clear() => _headers.Clear();
@ -206,6 +213,12 @@ namespace ln.http
Value = headerValue;
}
public Header(Header src)
{
Name = src.Name;
Value = src.Value;
}
public bool TryGetParameter(string parameterName, out string parameterValue)
{
if (_parameters is null)
@ -249,7 +262,8 @@ namespace ln.http
}
}
return this;
}
}
public override string ToString() => String.Format("{0}: {1}", Name, Value);
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Net;
using System.Text;
using ln.http.content;
namespace ln.http;
@ -46,7 +47,7 @@ public class Http1XConnection : HttpConnection
}
}
HttpRequestStream requestStream = null;
HttpContentStream contentStream = null;
if (headerContainer.TryGetInteger("content-length", out int contentLength))
{
if (headerContainer.TryGetValue("Expect", out string expectValue) && expectValue.Equals("100-continue"))
@ -56,10 +57,10 @@ public class Http1XConnection : HttpConnection
ConnectionStream.Write(statusLineBytes);
break;
}
requestStream = new HttpRequestStream(ConnectionStream, contentLength);
contentStream = new HttpContentStream(ConnectionStream, contentLength);
}
HttpRequestContext requestContext = new HttpRequestContext(Listener, this, ConnectionStream, new HttpRequest(_method, RequestUri, _httpVersion, false, headerContainer, requestStream));
HttpRequestContext requestContext = new HttpRequestContext(Listener, this, ConnectionStream, new HttpRequest(_method, RequestUri, _httpVersion, false, headerContainer, contentStream));
Listener.Dispatch(requestContext);
if (!ConnectionStream.CanWrite && !ConnectionStream.CanRead)

View File

@ -3,6 +3,7 @@ using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ln.http.content;
using ln.json;
namespace ln.http
@ -24,16 +25,16 @@ namespace ln.http
Dictionary<String, String> requestCookies;
public HttpRequestStream ContentStream { get; }
public HttpContentStream ContentStream { get; }
public HttpRequest(string requestMethodName, Uri requestUri, HttpVersion httpVersion, bool viaTLS, HeaderContainer headerContainer, HttpRequestStream requestStream)
public HttpRequest(string requestMethodName, Uri requestUri, HttpVersion httpVersion, bool viaTLS, HeaderContainer headerContainer, HttpContentStream contentStream)
{
RequestMethodName = requestMethodName;
RequestUri = requestUri;
HttpVersion = httpVersion;
Headers = headerContainer;
ViaTLS = viaTLS;
ContentStream = requestStream;
ContentStream = contentStream;
Initialize();
}

View File

@ -34,4 +34,17 @@ public static class HttpVersionSupport
throw new NotSupportedException();
}
}
public static HttpVersion Parse(string httpVersion)
{
switch (httpVersion)
{
case "HTTP/1.0":
return HttpVersion.HTTP10;
case "HTTP/1.1":
return HttpVersion.HTTP10;
default:
return HttpVersion.None;
}
}
}

View File

@ -1,18 +1,217 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using ln.http.content;
using ln.http.exceptions;
using ln.logging;
using ln.patterns;
using ln.type;
namespace ln.http.client
{
public class HttpClient
{
CookieContainer Cookies { get; set; } = new CookieContainer();
private CookieContainer _cookies = new CookieContainer();
private HeaderContainer _defaultHeaders = new HeaderContainer();
private ILogWriter _logWriter;
public HeaderContainer DefaultHeaders => _defaultHeaders;
public CookieContainer Cookies => _cookies;
public HttpClient() :this(null) { }
public bool FollowRedirects { get; set; }
public HttpClient()
public HttpClient(ILogWriter logWriter)
{
_logWriter = logWriter ?? new LogWriter(new ConsoleLogSink(true));
_defaultHeaders.Add("User-Agent", "Mozilla/5.0 (ln.http.client.HttpClient)");
}
public HttpClientRequest CreateRequest(URI uri)
private delegate bool TryConnectDelegate(Uri uri, IPAddress ip, int port, out IDisposable disposable,
out Stream connectionStream);
public Optional<HttpClientResponse> Request(HttpMethod method, Uri uri, IEnumerable<Header> headers,
Stream body)
{
return new HttpClientRequest(this, uri);
TryConnectDelegate tryConnectDelegate = null;
switch (uri.Scheme)
{
case "http":
tryConnectDelegate = this.TryConnect;
break;
case "https":
tryConnectDelegate = this.TryConnectTLS;
break;
default:
throw new NotSupportedException($"URI scheme { uri.Scheme } not supported by HttpClient");
}
foreach (var address in GetIPAddresses(uri))
{
if (tryConnectDelegate(uri, address, uri.Port, out IDisposable disposable, out Stream connectionStream))
{
try
{
return _Request(connectionStream, method, uri, headers, body);
}
finally
{
connectionStream?.Dispose();
disposable?.Dispose();
}
}
}
return new SocketException();
}
private IPAddress[] GetIPAddresses(Uri uri)
{
switch (uri.HostNameType)
{
case UriHostNameType.Dns:
return Dns.GetHostAddresses(uri.Host);
case UriHostNameType.IPv4:
case UriHostNameType.IPv6:
return new[] { IPAddress.Parse(uri.Host), };
default:
return new IPAddress[0];
}
}
private bool TryConnect(Uri uri, IPAddress ip, int port, out IDisposable disposable, out Stream connectionStream)
{
try
{
TcpClient tcpClient = new TcpClient();
tcpClient.Connect(new IPEndPoint(ip, port));
disposable = tcpClient;
connectionStream = tcpClient.GetStream();
return true;
}
catch (SocketException se)
{
}
disposable = null;
connectionStream = null;
return false;
}
private bool TryConnectTLS(Uri uri, IPAddress ip, int port, out IDisposable disposable, out Stream connectionStream)
{
if (TryConnect(uri, ip, port, out IDisposable rawDisposable, out Stream rawConnectionStream))
{
SslStream sslStream = new SslStream(rawConnectionStream, false);
sslStream.AuthenticateAsClient(uri.Host);
disposable = rawDisposable;
connectionStream = sslStream;
return true;
}
disposable = null;
connectionStream = null;
return false;
}
public Optional<HttpClientResponse> _Request(Stream connectionStream, HttpMethod method, Uri uri,
IEnumerable<Header> headers, Stream body)
{
HeaderContainer requestHeaders = new HeaderContainer(_defaultHeaders);
requestHeaders.Set("Host", uri.Host);
if (body is Stream)
requestHeaders.Set("Content-Length", body.Length.ToString());
connectionStream.WriteBytes(Encoding.ASCII.GetBytes(String.Format("{0} {1} HTTP/1.1\r\n",
method.ToString(),
uri.PathAndQuery
)));
requestHeaders.CopyTo(connectionStream);
if ((method != HttpMethod.HEAD) && (body is Stream))
body.CopyTo(connectionStream);
connectionStream.Flush();
if (!ReadResponseLine(connectionStream, out HttpVersion httpVersion, out HttpStatusCode statusCode,
out string reason))
return new ProtocolViolationException();
HeaderContainer responseHeaders = new HeaderContainer(connectionStream);
Stream contentStream = null;
if (method != HttpMethod.HEAD)
{
if (responseHeaders.TryGetValue("Transfer-Encoding", out string transferEncoding) &&
(transferEncoding.Equals("chunked")))
contentStream = new HttpContentStreamChunked(connectionStream);
else if (responseHeaders.TryGetInteger("Content-Length", out int contentLength))
contentStream = new HttpContentStream(connectionStream, contentLength);
}
if (FollowRedirects)
{
switch (statusCode)
{
case HttpStatusCode.Found:
case HttpStatusCode.MovedPermanently:
case HttpStatusCode.TemporaryRedirect:
case HttpStatusCode.PermanentRedirect:
if (body != null)
body.Position = 0;
return Request(method, new Uri(responseHeaders.Get("Location")), headers, body);
case HttpStatusCode.SeeOther:
return Get(responseHeaders.Get("Location"), headers);
}
}
return new HttpClientResponse(method, uri, statusCode, reason, responseHeaders, contentStream);
}
public Optional<HttpClientResponse> Get(Uri uri) => Request(HttpMethod.GET, uri, null, null);
public Optional<HttpClientResponse> Get(Uri uri, IEnumerable<Header> headers) => Request(HttpMethod.GET, uri, headers, null);
public Optional<HttpClientResponse> Post(Uri uri, Stream body) => Request(HttpMethod.POST, uri, null, body);
public Optional<HttpClientResponse> Post(Uri uri, IEnumerable<Header> headers, Stream body) => Request(HttpMethod.POST, uri, headers, body);
public Optional<HttpClientResponse> Get(string uri) => Request(HttpMethod.GET, new Uri(uri), null, null);
public Optional<HttpClientResponse> Get(string uri, IEnumerable<Header> headers) => Request(HttpMethod.GET, new Uri(uri), headers, null);
public Optional<HttpClientResponse> Post(string uri, Stream body) => Request(HttpMethod.POST, new Uri(uri), null, body);
public Optional<HttpClientResponse> Post(string uri, IEnumerable<Header> headers, Stream body) => Request(HttpMethod.POST, new Uri(uri), headers, body);
public static bool ReadResponseLine(Stream stream, out HttpVersion httpVersion, out HttpStatusCode statusCode,
out string reason)
{
string requestLine = HttpConnection.ReadLine(stream);
if (requestLine is null)
{
httpVersion = HttpVersion.None;
statusCode = 0;
reason = null;
return false;
}
int idxSpace1 = requestLine.IndexOf(' ');
int idxSpace2 = requestLine.IndexOf(' ', idxSpace1 + 1);
if ((idxSpace1 > 0) && (idxSpace2 > 0))
{
httpVersion = HttpVersionSupport.Parse(requestLine.Substring(0, idxSpace1));
statusCode = (HttpStatusCode)int.Parse(requestLine.Substring(idxSpace1 + 1, (idxSpace2 - idxSpace1 - 1)));
reason = requestLine.Substring(idxSpace2 + 1);
return true;
}
httpVersion = HttpVersion.None;
statusCode = 0;
reason = null;
return false;
}
}
}

View File

@ -1,108 +0,0 @@
using System;
using ln.type;
using System.IO;
using System.Net.Sockets;
using System.Net.Security;
namespace ln.http.client
{
public class HttpClientRequest
{
public HttpClient HttpClient { get; }
public URI URI
{
get => uri;
set
{
if (!value.Scheme.Equals("http") && !value.Scheme.Equals("https"))
throw new ArgumentOutOfRangeException(nameof(value), String.Format("unsupported url scheme: {0}", value.Scheme));
uri = value;
}
}
public HeaderContainer Headers { get; } = new HeaderContainer();
public String Method { get; set; }
URI uri;
MemoryStream contentStream;
HttpClientResponse clientResponse;
public HttpClientRequest(HttpClient httpClient, URI uri)
{
HttpClient = httpClient;
URI = uri;
Headers.Add("user-agent", "ln.http.client");
}
public Stream GetContentStream()
{
if (clientResponse != null)
throw new NotSupportedException("Request has already been executed, content stream has been disposed");
if (contentStream == null)
contentStream = new MemoryStream();
return contentStream;
}
public HttpClientResponse GetResponse()
{
if (clientResponse == null)
{
executeRequest();
}
return clientResponse;
}
private Stream OpenConnection()
{
TcpClient tcpClient = null;
SslStream sslStream = null;
try
{
tcpClient = new TcpClient();
tcpClient.ExclusiveAddressUse = false;
tcpClient.Connect(URI.Host, int.Parse(URI.Port));
if (!tcpClient.Connected)
throw new IOException(String.Format("could not connect to host {0}",uri.Host));
if (uri.Scheme.Equals("https"))
{
sslStream = new SslStream(tcpClient.GetStream());
return sslStream;
}
return tcpClient.GetStream();
} catch (Exception)
{
if (sslStream != null)
sslStream.Dispose();
if (tcpClient != null)
tcpClient.Dispose();
throw;
}
}
private void executeRequest()
{
if (contentStream.Length > 0)
{
byte[] requestContent = contentStream.ToArray();
Headers.Add("content-length", requestContent.Length.ToString());
}
Stream connectionStream = OpenConnection();
clientResponse = new HttpClientResponse(this);
contentStream.Dispose();
}
}
}

View File

@ -1,13 +1,35 @@
using System;
using System.IO;
using ln.http.content;
namespace ln.http.client
{
public class HttpClientResponse
{
public HttpClientRequest ClientRequest { get; }
public HttpMethod Method { get; }
public Uri Uri { get; }
public HttpStatusCode StatusCode { get; }
public string Reason { get; }
public HttpClientResponse(HttpClientRequest clientRequest)
private HeaderContainer _responseHeaders;
private Stream _contentStream;
public HeaderContainer Headers => _responseHeaders;
public Stream ContentStream => _contentStream;
public HttpClientResponse(HttpMethod method, Uri uri, HttpStatusCode statusCode, string reason, HeaderContainer responseHeaders, Stream contentStream)
{
ClientRequest = clientRequest;
Method = method;
Uri = uri;
StatusCode = statusCode;
Reason = reason;
_responseHeaders = responseHeaders;
_contentStream = contentStream;
}
public override string ToString() => String.Format("{0} {1}", (int)StatusCode, StatusCode);
}
}

View File

@ -1,16 +1,16 @@
using System;
using System.IO;
namespace ln.http;
namespace ln.http.content;
public class HttpRequestStream : Stream
public class HttpContentStream : Stream
{
public int MemoryLimit { get; set; } = 1024 * 1024 * 10;
private Stream _stream;
private string _tempFileName;
public HttpRequestStream(Stream connectionStream, int length)
public HttpContentStream(Stream connectionStream, int length)
{
byte[] transferBuffer;
// if (length <= MemoryLimit)

View File

@ -0,0 +1,61 @@
using System;
using System.IO;
namespace ln.http.content;
public class HttpContentStreamChunked : Stream
{
private Stream _stream;
private string _tempFileName;
public HttpContentStreamChunked(Stream connectionStream)
{
byte[] transferBuffer;
_tempFileName = Path.GetTempFileName();
_stream = new FileStream(_tempFileName, FileMode.Open, FileAccess.ReadWrite);
transferBuffer = new byte[1024 * 1024];
int nChunkSize;
do
{
string chunkSize = HttpConnection.ReadLine(connectionStream);
nChunkSize = int.Parse(chunkSize, System.Globalization.NumberStyles.HexNumber);
int length = nChunkSize;
while (length > 0)
{
int size = length > transferBuffer.Length ? transferBuffer.Length : length;
int nread = connectionStream.Read(transferBuffer, 0, size);
_stream.Write(transferBuffer, 0, nread);
length -= nread;
}
HttpConnection.ReadLine(connectionStream);
} while (nChunkSize != 0);
_stream.Position = 0;
}
public override int Read(byte[] buffer, int offset, int count) => _stream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin);
public override void SetLength(long value) => throw new System.NotImplementedException();
public override void Write(byte[] buffer, int offset, int count) => throw new System.NotImplementedException();
public override void Flush() => throw new System.NotImplementedException();
public override bool CanRead { get; } = true;
public override bool CanSeek { get; } = true;
public override bool CanWrite { get; } = false;
public override long Length => _stream.Length;
public override long Position { get => _stream.Position; set => _stream.Position = value; }
public override int Read(Span<byte> buffer) => _stream.Read(buffer);
public override void Close() => _stream.Close();
protected override void Dispose(bool disposing)
{
_stream.Dispose();
if (_tempFileName is not null)
File.Delete(_tempFileName);
}
}

View File

@ -9,7 +9,7 @@
<Copyright>(c) 2020 Harald Wolff-Thobaben</Copyright>
<PackageTags>http server</PackageTags>
<LangVersion>default</LangVersion>
<PackageVersion>0.9.8</PackageVersion>
<PackageVersion>0.9.9-preview0</PackageVersion>
<AssemblyVersion>0.6.2.0</AssemblyVersion>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
@ -18,6 +18,7 @@
<PackageReference Include="ln.collections" Version="0.2.2" />
<PackageReference Include="ln.json" Version="1.2.1" />
<PackageReference Include="ln.mime" Version="1.1.1" />
<PackageReference Include="ln.patterns" Version="0.1.0-preview7" />
<PackageReference Include="ln.threading" Version="0.2.2" />
<PackageReference Include="ln.type" Version="0.1.9" />
</ItemGroup>