diff --git a/README.md b/README.md index 8e4a739..bf876cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Yllibed HttpServer -This is a versatile http server designed to be used in mobile/UWP applications. + +This is a versatile http server designed to be used in mobile/UWP applications and any applications which need to expose a simple web server. ## Packages and NuGet Statistics @@ -17,9 +18,34 @@ This is a versatile http server designed to be used in mobile/UWP applications. 2. Register a server in your app: ```csharp - var myServer = new Server(8080); // create a web server on port 8080 - myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); // register a handler for the root - var (uri4, uri6) = myServer.Start(); // start the server and get the URIs + var myServer = new Server(); // Uses dynamic port (recommended) + myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); + var (uri4, uri6) = myServer.Start(); // Get the actual URIs with assigned ports + + Console.WriteLine($"Server running on: {uri4}"); + ``` + + Or specify a fixed port: + ```csharp + var myServer = new Server(8080); // Fixed port + myServer.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); + var (uri4, uri6) = myServer.Start(); + ``` + + Or with dependency injection and configuration: + ```csharp + var services = new ServiceCollection(); + services.AddYllibedHttpServer(opts => + { + opts.Port = 0; // Dynamic port (recommended) + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + }); + + var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); + var (uri4, uri6) = server.Start(); ``` ## What it is @@ -30,17 +56,158 @@ This is a versatile http server designed to be used in mobile/UWP applications. * This HTTP server is not designed for performance or high capacity * It's perfect for small applications, or small need, like to act as _return url_ for OAuth2 authentication using external browser. +## Features +* Simple, lightweight, self-contained HTTP server +* Supports IPv4 and IPv6 +* Supports HTTP 1.1 (limited: no keep-alive, no chunked encoding) +* Supports GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE, PATCH - even custom methods +* Supports static files +* Supports custom headers +* Supports custom status codes +* Supports custom content types +* Supports custom content encodings +* Supports dependency injection and configuration via `IOptions` +* Configurable bind addresses and hostnames for IPv4/IPv6 +* Supports dynamic port assignment + +## Common use cases +* Return URL for OAuth2 authentication using external browser +* Remote diagnostics/monitoring on your app +* Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration) +* Any other use case where you need to expose a simple web server + ## Limitations * There is no support for HTTP 2.0+ (yet) or WebSockets * There is no support for HTTPS (TLS) -## What you can do with it -* Use it for building a headless Windows IoT app (for SSDP discovery or simply end-user configuration) -* Use it for remote diagnostics/monitoring on your app +## Security and Intended Use (No TLS) +This server uses plain HTTP with no transport encryption. It is primarily intended for: +- Localhost usage (loopback) during development or as an OAuth redirect target. +- Internal communication on trusted networks, e.g., between Docker containers on the same host or in a private overlay network. +- Embedded/local scenarios (IoT, diagnostics) where transport security is handled elsewhere. + +If you need to expose it on a public or untrusted network: +- Put it behind a reverse proxy that terminates TLS (e.g., Nginx, Traefik, Caddy, IIS/ASP.NET Core reverse proxy) and forward to this server over HTTP on a private interface. +- Alternatively, use a secure tunnel (SSH, Cloudflare Tunnel, etc.). +- Bind to loopback only (127.0.0.1 / ::1) when you want to ensure local-only access. + +Note: Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed. + +## Configuration and Dependency Injection + +The server can now be configured via a `ServerOptions` POCO. You can construct `Server` directly with a `ServerOptions` instance, or register it with Microsoft.Extensions.DependencyInjection using `IOptions`. + +### ServerOptions Properties + +* `Port` - Port number to listen to (0 = dynamic) +* `BindAddress4` - IPv4 address to bind the listener to (defaults to `IPAddress.Any`) +* `BindAddress6` - IPv6 address to bind the listener to (defaults to `IPAddress.IPv6Any`) +* `Hostname4` - Hostname used to compose the public IPv4 URI (defaults to "127.0.0.1") +* `Hostname6` - Hostname used to compose the public IPv6 URI (defaults to "::1") + +### Dynamic Port Assignment (Recommended) + +Using port `0` enables **dynamic port assignment**, which is the **recommended approach** for most applications: + +**Key Advantages:** +- **Zero port conflicts**: The operating system automatically assigns an available port +- **Perfect for testing**: Multiple test instances can run simultaneously without conflicts +- **Microservices architecture**: Each service gets its own unique port automatically +- **Team collaboration**: No need to coordinate port assignments between developers +- **CI/CD friendly**: Parallel builds and tests work seamlessly + +**Best Practices with Dynamic Ports:** + +```csharp +// ✅ Recommended: Use dynamic ports (default behavior) +var server = new Server(); // Port 0 by default +server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello!")); +var (uri4, uri6) = server.Start(); + +// Capture the actual assigned port for logging or service discovery +var actualPort = new Uri(uri4).Port; +Console.WriteLine($"Server started on port: {actualPort}"); +Console.WriteLine($"Access your service at: {uri4}"); + +// For service registration with discovery systems +await RegisterWithServiceDiscovery(uri4); +``` + +**In production with Dependency Injection:** -# Tips +```csharp +services.AddYllibedHttpServer(); // Uses port 0 by default - no conflicts! -## Opening port on Windows 10 IoT (typically on a Raspberry Pi) +// Or be explicit for documentation purposes: +services.AddYllibedHttpServer(opts => +{ + opts.Port = 0; // Dynamic port - recommended + opts.BindAddress4 = IPAddress.Any; // Accept from all interfaces + opts.Hostname4 = Environment.MachineName; // Use machine name in URIs +}); +``` + +**When NOT to use dynamic ports:** +- Public-facing web services requiring well-known ports (80, 443) +- Legacy systems expecting fixed ports +- Load balancer configurations requiring static endpoints +- Development scenarios where you need predictable URLs + +### Basic Configuration Example + +```csharp +var serverOptions = new ServerOptions +{ + Port = 5000, + Hostname4 = "192.168.1.100", // Custom hostname for callbacks + BindAddress4 = IPAddress.Any // Listen on all interfaces +}; + +var server = new Server(serverOptions); +server.Start(); +``` + +### Dependency Injection Example + +The cleanest approach using extension methods: + +```csharp +var services = new ServiceCollection(); +services.AddYllibedHttpServer(opts => +{ + opts.Port = 5000; + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + opts.BindAddress4 = IPAddress.Parse("0.0.0.0"); +}); + +var sp = services.BuildServiceProvider(); +var server = sp.GetRequiredService(); +server.Start(); +``` + +Alternative using `services.Configure<>()` and automatic constructor selection: + +```csharp +var services = new ServiceCollection(); +services.Configure(opts => +{ + opts.Port = 5000; + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; +}); +services.AddSingleton(); // Uses IOptions constructor automatically + +var sp = services.BuildServiceProvider(); +var server = sp.GetRequiredService(); +server.Start(); +``` + +This allows you to control the bind addresses and hostnames used to compose the public URIs that the server logs, which is especially useful for OAuth callbacks and REST API applications. + +## Tips + +### Opening port on Windows 10 IoT (typically on a Raspberry Pi) If you want to open "any" port on a Raspberry Pi running Windows 10 IoT, you may need to open a port. @@ -52,3 +219,44 @@ need to open a port. ```shell netsh advfirewall firewall add rule name="My Application Webserver" dir=in action=allow protocol=TCP localport=8080 ``` + +### OAuth2 Callback Configuration +When using this server for OAuth2 callbacks, dynamic ports are especially useful: + +```csharp +services.AddYllibedHttpServer(); // Dynamic port prevents conflicts + +var server = serviceProvider.GetRequiredService(); +server.RegisterHandler(new MyOAuthCallbackHandler()); +var (uri4, uri6) = server.Start(); + +// Use the actual URI for OAuth redirect registration +var redirectUri = $"{uri4}/oauth/callback"; +Console.WriteLine($"Register this redirect URI with your OAuth provider: {redirectUri}"); + +// No port conflicts even if multiple OAuth flows run simultaneously! +``` + +For scenarios requiring fixed callback URLs: +```csharp +services.Configure(opts => +{ + opts.Port = 5001; + opts.Hostname4 = "127.0.0.1"; // Match your OAuth redirect URI + opts.BindAddress4 = IPAddress.Loopback; // Only accept local connections +}); +``` + +### Listening on All Interfaces +To accept connections from other machines on your network: + +```csharp +var serverOptions = new ServerOptions +{ + Port = 8080, + BindAddress4 = IPAddress.Any, // Listen on all IPv4 interfaces + BindAddress6 = IPAddress.IPv6Any, // Listen on all IPv6 interfaces + Hostname4 = "192.168.1.100", // Your actual IP for public URIs + Hostname6 = "::1" // IPv6 loopback +}; +``` diff --git a/Yllibed.HttpServer.Json.Tests/FixtureBase.cs b/Yllibed.HttpServer.Json.Tests/FixtureBase.cs index a3cde3d..e83d4fb 100644 --- a/Yllibed.HttpServer.Json.Tests/FixtureBase.cs +++ b/Yllibed.HttpServer.Json.Tests/FixtureBase.cs @@ -4,26 +4,22 @@ using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Yllibed.HttpServer.Json.Tests -{ - public class FixtureBase - { - protected CancellationToken CT; - private CancellationTokenSource _ctSource; +namespace Yllibed.HttpServer.Json.Tests; - [TestInitialize] - public void Initialize() - { - _ctSource = Debugger.IsAttached - ? new CancellationTokenSource() - : new CancellationTokenSource(TimeSpan.FromSeconds(10)); - CT = _ctSource.Token; - } +public class FixtureBase +{ + protected CancellationToken CT; + private CancellationTokenSource _ctSource; - [TestCleanup] - public void Terminate() - { - _ctSource.Dispose(); - } + [TestInitialize] + public void Initialize() + { + _ctSource = Debugger.IsAttached + ? new CancellationTokenSource() + : new CancellationTokenSource(TimeSpan.FromSeconds(10)); + CT = _ctSource.Token; } + + [TestCleanup] + public void Terminate() => _ctSource.Dispose(); } diff --git a/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs index c267fe1..7f0b442 100644 --- a/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs +++ b/Yllibed.HttpServer.Json.Tests/JsonHandlerBaseFixture.cs @@ -7,59 +7,63 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; +using Yllibed.HttpServer.Json; -namespace Yllibed.HttpServer.Json.Tests +namespace Yllibed.HttpServer.Json.Tests; + +[TestClass] +public class JsonHandlerBaseFixture : FixtureBase { - [TestClass] - public class JsonHandlerBaseFixture : FixtureBase + [TestMethod] + public async Task TestSimpleGet_WithJsonHandler() { - [TestMethod] - public async Task TestSimpleGet_WithJsonHandler() - { - var server = new Server(); + var server = new Server(); - var (uri4, uri6) = server.Start(); - var requestUri = uri4 + "abcd?a=1&b=%20%2020"; + var (uri4, uri6) = server.Start(); + var requestUri = uri4 + "abcd?a=1&b=%20%2020"; - var sut = new MyJsonHandler("GET", "abcd"); + var sut = new MyJsonHandler("GET", "abcd"); - using (server.RegisterHandler(sut)) + using (server.RegisterHandler(sut)) + { + using (var client = new HttpClient()) { - using (var client = new HttpClient()) - { - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.OK); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(CT)); - result.Should().NotBeNull(); - result.Should().BeEquivalentTo(new { A = "1", B = " 20" }); - } + var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false)); + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(new { A = "1", B = " 20" }); } } + } - public class MyResultPayload + public class MyResultPayload + { + public string? A { get; set; } + public string? B { get; set; } + } + + private sealed class MyJsonHandler : JsonHandlerBase + { + public MyJsonHandler(string method, string path) : base(method, path) { - public string? A { get; set; } - public string? B { get; set; } } - private sealed class MyJsonHandler(string method, string path) : JsonHandlerBase(method, path) + protected override async Task<(MyResultPayload result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters) { - protected override async Task<(MyResultPayload result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters) - { - queryParameters.TryGetValue("a", out var a); - queryParameters.TryGetValue("b", out var b); + queryParameters.TryGetValue("a", out var a); + queryParameters.TryGetValue("b", out var b); - await Task.Yield(); + await Task.Yield(); - var result = new MyResultPayload - { - A = a?.FirstOrDefault(), - B = b?.FirstOrDefault(), - }; + var result = new MyResultPayload + { + A = a?.FirstOrDefault(), + B = b?.FirstOrDefault(), + }; - return (result, 200); - } + return (result, 200); } } } diff --git a/Yllibed.HttpServer.Json/JsonHandlerBase.cs b/Yllibed.HttpServer.Json/JsonHandlerBase.cs index b0acab1..786f80e 100644 --- a/Yllibed.HttpServer.Json/JsonHandlerBase.cs +++ b/Yllibed.HttpServer.Json/JsonHandlerBase.cs @@ -9,73 +9,72 @@ using Newtonsoft.Json; using Yllibed.Framework.Logging; -namespace Yllibed.HttpServer.Json +namespace Yllibed.HttpServer.Json; + +public abstract class JsonHandlerBase : IHttpHandler + where TResult : class { - public abstract class JsonHandlerBase : IHttpHandler - where TResult : class + private readonly string _method; + private readonly string _path; + + protected JsonHandlerBase(string method, string path) { - private readonly string _method; - private readonly string _path; + _method = method; + _path = path; - protected JsonHandlerBase(string method, string path) + if (!_path.StartsWith("/", StringComparison.Ordinal)) { - _method = method; - _path = path; - - if (!_path.StartsWith("/", StringComparison.Ordinal)) - { - _path = "/" + _path; - } + _path = "/" + _path; } + } - public async Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + public async Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + { + if (!request.Method.Equals(_method, StringComparison.OrdinalIgnoreCase)) { - if (!request.Method.Equals(_method, StringComparison.OrdinalIgnoreCase)) - { - return; // wrong method - } + return; // wrong method + } - var queryParts = relativePath.Split('?').ToList(); + var queryParts = relativePath.Split('?').ToList(); - if (queryParts[0].Equals(_path, StringComparison.OrdinalIgnoreCase)) + if (queryParts[0].Equals(_path, StringComparison.OrdinalIgnoreCase)) + { + try { - try - { - var queryParameters = ParseQueryParameters(queryParts.Skip(1).FirstOrDefault()); - (var result, var statusCode) = await ProcessRequest(ct, relativePath, queryParameters).ConfigureAwait(true); + var queryParameters = ParseQueryParameters(queryParts.Skip(1).FirstOrDefault()); + (var result, var statusCode) = await ProcessRequest(ct, relativePath, queryParameters).ConfigureAwait(true); - var json = JsonConvert.SerializeObject(result, Formatting.Indented); + var json = JsonConvert.SerializeObject(result, Formatting.Indented); - request.SetResponse("application/json", json, statusCode); - } - catch (Exception ex) - { - this.Log().LogError("Error processing request for path {RelativePath}, error={Message}", relativePath, ex.Message); - request.SetResponse("text/plain", "Error processing request", 500, "ERROR"); - } + request.SetResponse("application/json", json, statusCode); + } + catch (Exception ex) + { + this.Log().LogError("Error processing request for path {RelativePath}, error={Message}", relativePath, ex.Message); + request.SetResponse("text/plain", "Error processing request", 500, "ERROR"); } } + } #pragma warning disable MA0002 - private IDictionary ParseQueryParameters(string? queryPart) + private IDictionary ParseQueryParameters(string? queryPart) + { + if (queryPart is null or { Length: 0 }) { - if (queryPart is null or { Length: 0 }) - { - return ImmutableDictionary.Empty; - } - - var result = queryPart - .Split('&') - .Select(v => v.Split(['='], 2)) - .Where(v => v.Length == 2) - .GroupBy(v => v[0], v => Uri.UnescapeDataString(v[1])) - .Select(x => new KeyValuePair(x.Key, x.ToArray())) - .ToImmutableDictionary(); - - return result; + return ImmutableDictionary.Empty; } - protected abstract Task<(TResult result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters); + var result = queryPart + .Split('&') + .Select(v => v.Split(['='], 2)) + .Where(v => v.Length == 2) + .GroupBy(v => v[0], v => Uri.UnescapeDataString(v[1])) + .Select(x => new KeyValuePair(x.Key, x.ToArray())) + .ToImmutableDictionary(); + + return result; } + + protected abstract Task<(TResult result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary queryParameters); } diff --git a/Yllibed.HttpServer.Tests/DiFixture.cs b/Yllibed.HttpServer.Tests/DiFixture.cs new file mode 100644 index 0000000..5a3561e --- /dev/null +++ b/Yllibed.HttpServer.Tests/DiFixture.cs @@ -0,0 +1,131 @@ +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Yllibed.HttpServer.Extensions; + +namespace Yllibed.HttpServer.Tests; + +[TestClass] +public class DiFixture : FixtureBase +{ + [TestMethod] + public async Task Server_CanBeResolvedFromDI_WithConfigureOptions() + { + var services = new ServiceCollection(); + + // Use Configure<> like in the README example + services.Configure(opts => + { + opts.Port = 0; // Use dynamic port for test + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + opts.BindAddress4 = System.Net.IPAddress.Loopback; + opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback; + }); + + // Register Server explicitly via factory to avoid ambiguous constructor selection + services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + + var sp = services.BuildServiceProvider(); + + var server = sp.GetRequiredService(); + using (server) + { + var (uri4, uri6) = server.Start(); + + using var client = new HttpClient(); + var response = await client.GetAsync(uri4, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } + } + + [TestMethod] + public async Task Server_CanBeResolvedFromDI_WithActivatorUtilitiesConstructor() + { + var services = new ServiceCollection(); + + services.Configure(opts => + { + opts.Port = 0; + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + opts.BindAddress4 = System.Net.IPAddress.Loopback; + opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback; + }); + + // This should now work without explicit factory thanks to [ActivatorUtilitiesConstructor] + services.AddSingleton(); + + var sp = services.BuildServiceProvider(); + + var server = sp.GetRequiredService(); + using (server) + { + var (uri4, uri6) = server.Start(); + + using var client = new HttpClient(); + var response = await client.GetAsync(uri4, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } + } + + [TestMethod] + public async Task Server_CanBeRegisteredWithExtensionMethod() + { + var services = new ServiceCollection(); + + // Using the extension method - cleanest approach + services.AddYllibedHttpServer(opts => + { + opts.Port = 0; + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + opts.BindAddress4 = System.Net.IPAddress.Loopback; + opts.BindAddress6 = System.Net.IPAddress.IPv6Loopback; + }); + + var sp = services.BuildServiceProvider(); + + var server = sp.GetRequiredService(); + using (server) + { + var (uri4, uri6) = server.Start(); + + using var client = new HttpClient(); + var response = await client.GetAsync(uri4, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } + } + + [TestMethod] + public async Task README_Example_Works() + { + // Test the exact example from README but with dynamic port + var services = new ServiceCollection(); + services.Configure(opts => + { + opts.Port = 0; // Dynamic port for test instead of 8080 + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + }); + services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + + var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + server.RegisterHandler(new Yllibed.HttpServer.Handlers.StaticHandler("/", "text/plain", "Hello, world!")); + + using (server) + { + var (uri4, uri6) = server.Start(); + + using var client = new HttpClient(); + var response = await client.GetAsync(uri4, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false); + content.Should().Be("Hello, world!"); + } + } +} diff --git a/Yllibed.HttpServer.Tests/FixtureBase.cs b/Yllibed.HttpServer.Tests/FixtureBase.cs index 25a0faf..a43a315 100644 --- a/Yllibed.HttpServer.Tests/FixtureBase.cs +++ b/Yllibed.HttpServer.Tests/FixtureBase.cs @@ -4,23 +4,22 @@ using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Yllibed.HttpServer.Tests -{ - public class FixtureBase - { - protected CancellationToken CT; - private CancellationTokenSource _ctSource; +namespace Yllibed.HttpServer.Tests; - [TestInitialize] - public void Initialize() - { - _ctSource = Debugger.IsAttached - ? new CancellationTokenSource() - : new CancellationTokenSource(TimeSpan.FromSeconds(10)); - CT = _ctSource.Token; - } +public class FixtureBase +{ + protected CancellationToken CT; + private CancellationTokenSource _ctSource; - [TestCleanup] - public void Terminate() => _ctSource.Dispose(); + [TestInitialize] + public void Initialize() + { + _ctSource = Debugger.IsAttached + ? new CancellationTokenSource() + : new CancellationTokenSource(TimeSpan.FromSeconds(10)); + CT = _ctSource.Token; } + + [TestCleanup] + public void Terminate() => _ctSource.Dispose(); } diff --git a/Yllibed.HttpServer.Tests/HttpServerFixture.cs b/Yllibed.HttpServer.Tests/HttpServerFixture.cs index aed39b5..1e84600 100644 --- a/Yllibed.HttpServer.Tests/HttpServerFixture.cs +++ b/Yllibed.HttpServer.Tests/HttpServerFixture.cs @@ -8,268 +8,267 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Yllibed.HttpServer.Handlers; -namespace Yllibed.HttpServer.Tests +namespace Yllibed.HttpServer.Tests; + +[TestClass] +public class HttpServerFixture : FixtureBase { - [TestClass] - public class HttpServerFixture : FixtureBase + [TestMethod] + public async Task HttpServer_Ipv4_Test404WhenNoHandlers() { - [TestMethod] - public async Task HttpServer_Ipv4_Test404WhenNoHandlers() - { - using var sut = new Server(); + using var sut = new Server(); - var (uri4, uri6) = sut.Start(); - var requestUri = uri4; + var (uri4, uri6) = sut.Start(); + var requestUri = uri4; - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [TestMethod] - public async Task HttpServer_Ipv6_Test404WhenNoHandlers() - { - using var sut = new Server(); + [TestMethod] + public async Task HttpServer_Ipv6_Test404WhenNoHandlers() + { + using var sut = new Server(); - var (uri4, uri6) = sut.Start(); - var requestUri = uri6; + var (uri4, uri6) = sut.Start(); + var requestUri = uri6; - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [TestMethod] - public async Task HttpServer_TestWithStaticHandler() - { - using var sut = new Server(); - sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); + [TestMethod] + public async Task HttpServer_TestWithStaticHandler() + { + using var sut = new Server(); + sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT); - responseContent.Should().Be("1234"); - } + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT); + responseContent.Should().Be("1234"); + } - [TestMethod] - public async Task HttpServer_TestWithStaticHandler_WithPost_ShouldReturn404() - { - using var sut = new Server(); - sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); + [TestMethod] + public async Task HttpServer_TestWithStaticHandler_WithPost_ShouldReturn404() + { + using var sut = new Server(); + sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); - using var client = new HttpClient(); - var response = await client.PostAsync(requestUri, new StringContent(string.Empty), CT); - response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - } + using var client = new HttpClient(); + var response = await client.PostAsync(requestUri, new StringContent(string.Empty), CT); + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } - [TestMethod] - public async Task HttpServer_TestWithStaticHandlerOnRoot() - { - using var sut = new Server(); - sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); + [TestMethod] + public async Task HttpServer_TestWithStaticHandlerOnRoot() + { + using var sut = new Server(); + sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT); - responseContent.Should().Be("1234"); - } + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT); + responseContent.Should().Be("1234"); + } + + // Load tests + [TestMethod] + public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultipleRequests() + { + using var sut = new Server(); + sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); - // Load tests - [TestMethod] - public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultipleRequests() + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); + + using var client = new HttpClient(); + var tasks = new Task[100]; + for (var i = 0; i < tasks.Length; i++) { - using var sut = new Server(); - sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); + tasks[i] = client.GetAsync(requestUri, CT); + } - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + await Task.WhenAll(tasks); + } - using var client = new HttpClient(); - var tasks = new Task[100]; - for (var i = 0; i < tasks.Length; i++) + private sealed class RandomlySlowHandler : IHttpHandler + { + private static readonly Random Rnd = new(); + + public async Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + { + await Task.Delay(Rnd.Next(1, 100), ct).ConfigureAwait(false); + + if (request.Method.Equals("POST", StringComparison.Ordinal)) { - tasks[i] = client.GetAsync(requestUri, CT); + var requestBody = request.Body ?? "EMPTY"; + request.SetResponse(request.ContentType ?? "text/plain", requestBody); + return; } - await Task.WhenAll(tasks); + request.SetResponse("text/plain", "1234"); } + } - private sealed class RandomlySlowHandler : IHttpHandler - { - private static readonly Random Rnd = new(); - - public async Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) - { - await Task.Delay(Rnd.Next(1, 100), ct).ConfigureAwait(false); + [TestMethod] + [DataRow(2)] + [DataRow(20)] + [DataRow(200)] + [DataRow(2000)] + public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultipleGetRequests_WithDelay(int count) + { + using var sut = new Server(); + sut.RegisterHandler(new RandomlySlowHandler()); - if (request.Method.Equals("POST", StringComparison.Ordinal)) - { - var requestBody = request.Body ?? "EMPTY"; - request.SetResponse(request.ContentType ?? "text/plain", requestBody); - return; - } + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); - request.SetResponse("text/plain", "1234"); - } + using var client = new HttpClient(); + var tasks = new Task[count]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = client.GetAsync(requestUri, CT); } - [TestMethod] - [DataRow(2)] - [DataRow(20)] - [DataRow(200)] - [DataRow(2000)] - public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultipleGetRequests_WithDelay(int count) - { - using var sut = new Server(); - sut.RegisterHandler(new RandomlySlowHandler()); + await Task.WhenAll(tasks); + } - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + [TestMethod] + [DataRow(2)] + [DataRow(20)] + [DataRow(200)] + [DataRow(2000)] + public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultiplePostRequests_WithDelay(int count) + { + using var sut = new Server(); + sut.RegisterHandler(new RandomlySlowHandler()); - using var client = new HttpClient(); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); + + using (var client = new HttpClient()) + { var tasks = new Task[count]; for (var i = 0; i < tasks.Length; i++) { - tasks[i] = client.GetAsync(requestUri, CT); + tasks[i] = PostRequest(i); + } + + async Task PostRequest(int i) + { + var content = $"Content {i:x8}"; + var response = await client + .PostAsync(requestUri, new StringContent(content), CT) + .ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false); + responseContent.Should().Be(content); } await Task.WhenAll(tasks); } + } - [TestMethod] - [DataRow(2)] - [DataRow(20)] - [DataRow(200)] - [DataRow(2000)] - public async Task HttpServer_TestWithStaticHandlerOnRoot_WithMultiplePostRequests_WithDelay(int count) - { - using var sut = new Server(); - sut.RegisterHandler(new RandomlySlowHandler()); + [TestMethod] + public async Task HttpServer_TestWithRelativePathHandler() + { + using var sut = new Server(); + var relativePathHandler = new RelativePathHandler("abcd"); + relativePathHandler.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); + sut.RegisterHandler(relativePathHandler); + + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd/efgh"); + + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT); + responseContent.Should().Be("1234"); + } - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + [TestMethod] + public async Task HttpServer_TestWithRelativePathHandler_MultiLevel() + { + using var sut = new Server(); + var rootPath = new RelativePathHandler("abcd"); + rootPath.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); + sut.RegisterHandler(rootPath); - using (var client = new HttpClient()) - { - var tasks = new Task[count]; - for (var i = 0; i < tasks.Length; i++) - { - tasks[i] = PostRequest(i); - } - - async Task PostRequest(int i) - { - var content = $"Content {i:x8}"; - var response = await client - .PostAsync(requestUri, new StringContent(content), CT) - .ConfigureAwait(false); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false); - responseContent.Should().Be(content); - } - - await Task.WhenAll(tasks); - } - } + var subPath1 = new RelativePathHandler("efgh"); + subPath1.RegisterHandler(new StaticHandler("ijkl", "text/plain", "5678")); + rootPath.RegisterHandler(subPath1); - [TestMethod] - public async Task HttpServer_TestWithRelativePathHandler() - { - using var sut = new Server(); - var relativePathHandler = new RelativePathHandler("abcd"); - relativePathHandler.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); - sut.RegisterHandler(relativePathHandler); + var (uri4, uri6) = sut.Start(); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd/efgh"); + await Check(new Uri(uri4, "abcd/efgh"), "1234"); + await Check(new Uri(uri4, "abcd/efgh/ijkl"), "5678"); + async Task Check(Uri uri, string expected) + { using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); + var response = await client.GetAsync(uri, CT).ConfigureAwait(false); response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT); - responseContent.Should().Be("1234"); - } - - [TestMethod] - public async Task HttpServer_TestWithRelativePathHandler_MultiLevel() - { - using var sut = new Server(); - var rootPath = new RelativePathHandler("abcd"); - rootPath.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); - sut.RegisterHandler(rootPath); - - var subPath1 = new RelativePathHandler("efgh"); - subPath1.RegisterHandler(new StaticHandler("ijkl", "text/plain", "5678")); - rootPath.RegisterHandler(subPath1); - - var (uri4, uri6) = sut.Start(); - - await Check(new Uri(uri4, "abcd/efgh"), "1234"); - await Check(new Uri(uri4, "abcd/efgh/ijkl"), "5678"); - - async Task Check(Uri uri, string expected) - { - using var client = new HttpClient(); - var response = await client.GetAsync(uri, CT).ConfigureAwait(false); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false); - responseContent.Should().Be(expected); - } + var responseContent = await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false); + responseContent.Should().Be(expected); } + } - [TestMethod] - public async Task HttpServer_TestWithRelativePathHandler_RegisterAndUnregister() - { - using var sut = new Server(); - var relativePathHandler = new RelativePathHandler("abcd"); - relativePathHandler.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); - var registration = sut.RegisterHandler(relativePathHandler); + [TestMethod] + public async Task HttpServer_TestWithRelativePathHandler_RegisterAndUnregister() + { + using var sut = new Server(); + var relativePathHandler = new RelativePathHandler("abcd"); + relativePathHandler.RegisterHandler(new StaticHandler("efgh", "text/plain", "1234")); + var registration = sut.RegisterHandler(relativePathHandler); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd/efgh"); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd/efgh"); - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT); - responseContent.Should().Be("1234"); + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT); + responseContent.Should().Be("1234"); - registration.Dispose(); + registration.Dispose(); - response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } - [TestMethod] - public async Task HttpServer_TestWithStaticHandler_RegisterAndUnregister() - { - using var sut = new Server(); - var registration = sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); + [TestMethod] + public async Task HttpServer_TestWithStaticHandler_RegisterAndUnregister() + { + using var sut = new Server(); + var registration = sut.RegisterHandler(new StaticHandler("abcd", "text/plain", "1234")); - var (uri4, uri6) = sut.Start(); - var requestUri = new Uri(uri4, "abcd"); + var (uri4, uri6) = sut.Start(); + var requestUri = new Uri(uri4, "abcd"); - using var client = new HttpClient(); - var response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var responseContent = await response.Content.ReadAsStringAsync(CT); - responseContent.Should().Be("1234"); + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(CT); + responseContent.Should().Be("1234"); - registration.Dispose(); + registration.Dispose(); - response = await client.GetAsync(requestUri, CT); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } + response = await client.GetAsync(requestUri, CT); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); } } diff --git a/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs b/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs new file mode 100644 index 0000000..31aa085 --- /dev/null +++ b/Yllibed.HttpServer.Tests/ServerOptionsFixture.cs @@ -0,0 +1,58 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Yllibed.HttpServer.Tests; + +[TestClass] +public class ServerOptionsFixture +{ + [TestMethod] + public void ServerOptions_InitValues_AreUsedByServer() + { + var options = new ServerOptions + { + Port = 0, + BindAddress4 = IPAddress.Loopback, + BindAddress6 = IPAddress.IPv6Loopback, + Hostname4 = "127.0.0.5", + Hostname6 = "::5" + }; + + using var sut = new Server(options); + var (uri4, uri6) = sut.Start(); + + // Hostnames should be used in the composed URIs + uri4.Host.Should().Be("127.0.0.5"); + // IPv6 host in Uri.Host returns without brackets, but ToString/AbsoluteUri contains brackets + uri6.Host.Trim('[', ']').Should().Be("::5"); + uri6.AbsoluteUri.Should().Contain("[::5]"); + } + + [TestMethod] + public async Task ServerOptions_CustomHostnames_CanAcceptConnections() + { + var options = new ServerOptions + { + Port = 0, + BindAddress4 = IPAddress.Loopback, + BindAddress6 = IPAddress.IPv6Loopback, + Hostname4 = "127.0.0.1", + Hostname6 = "::1" + }; + + using var sut = new Server(options); + var (uri4, uri6) = sut.Start(); + + using var client = new HttpClient(); + var response4 = await client.GetAsync(uri4).ConfigureAwait(false); + response4.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + + using var client6 = new HttpClient(); + var response6 = await client6.GetAsync(uri6).ConfigureAwait(false); + response6.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); + } +} diff --git a/Yllibed.HttpServer/Extensions/Disposable.cs b/Yllibed.HttpServer/Extensions/Disposable.cs index 35bd8e8..9f2944a 100644 --- a/Yllibed.HttpServer/Extensions/Disposable.cs +++ b/Yllibed.HttpServer/Extensions/Disposable.cs @@ -2,7 +2,7 @@ namespace Yllibed.HttpServer.Extensions; -public static class Disposable +internal static class Disposable { private sealed class DisposableAction : IDisposable { diff --git a/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b82cf7d --- /dev/null +++ b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Yllibed.HttpServer.Extensions; + +/// +/// Extension methods for registering Yllibed.HttpServer with dependency injection. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Yllibed HttpServer to the service collection using IOptions<ServerOptions> configuration. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddYllibedHttpServer(this IServiceCollection services) + { + services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + return services; + } + + /// + /// Adds the Yllibed HttpServer to the service collection with configuration. + /// + /// The service collection. + /// Configure the server options. + /// The service collection for chaining. + public static IServiceCollection AddYllibedHttpServer(this IServiceCollection services, Action configureOptions) + { + services.Configure(configureOptions); + services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + return services; + } +} diff --git a/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs b/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs index c91b841..4bf551c 100644 --- a/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs +++ b/Yllibed.HttpServer/Extensions/TextWriterExtensions.cs @@ -2,28 +2,17 @@ using System.IO; using System.Threading.Tasks; -namespace Yllibed.HttpServer.Extensions +namespace Yllibed.HttpServer.Extensions; + +#pragma warning disable MA0045 // Don't force using ct here + +internal static class TextWriterExtensions { - public static class TextWriterExtensions - { - public static Task WriteFormattedLineAsync(this TextWriter writer, FormattableString str) - { - return writer.WriteLineAsync(str.ToString(writer.FormatProvider)); - } + public static Task WriteFormattedLineAsync(this TextWriter writer, FormattableString str) => writer.WriteLineAsync(str.ToString(writer.FormatProvider)); - public static Task WriteFormattedAsync(this TextWriter writer, FormattableString str) - { - return writer.WriteAsync(str.ToString(writer.FormatProvider)); - } + public static Task WriteFormattedAsync(this TextWriter writer, FormattableString str) => writer.WriteAsync(str.ToString(writer.FormatProvider)); - public static void WriteFormattedLine(this TextWriter writer, FormattableString str) - { - writer.WriteLine(str.ToString(writer.FormatProvider)); - } + public static void WriteFormattedLine(this TextWriter writer, FormattableString str) => writer.WriteLine(str.ToString(writer.FormatProvider)); - public static void WriteFormatted(this TextWriter writer, FormattableString str) - { - writer.Write(str.ToString(writer.FormatProvider)); - } - } + public static void WriteFormatted(this TextWriter writer, FormattableString str) => writer.Write(str.ToString(writer.FormatProvider)); } diff --git a/Yllibed.HttpServer/Handlers/IHttpHandler.cs b/Yllibed.HttpServer/Handlers/IHttpHandler.cs index ad2c424..26d80b7 100644 --- a/Yllibed.HttpServer/Handlers/IHttpHandler.cs +++ b/Yllibed.HttpServer/Handlers/IHttpHandler.cs @@ -14,4 +14,4 @@ namespace Yllibed.HttpServer.Handlers; public interface IHttpHandler { Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath); -} \ No newline at end of file +} diff --git a/Yllibed.HttpServer/Logging/DefaultLogger.cs b/Yllibed.HttpServer/Logging/DefaultLogger.cs index e0f85db..ccbac20 100644 --- a/Yllibed.HttpServer/Logging/DefaultLogger.cs +++ b/Yllibed.HttpServer/Logging/DefaultLogger.cs @@ -1,14 +1,10 @@ using Microsoft.Extensions.Logging; -namespace Yllibed.Framework.Logging +namespace Yllibed.Framework.Logging; + +public static class DefaultLogger { - public static class DefaultLogger - { - public static ILoggerFactory LoggerFactory { get; } + public static ILoggerFactory LoggerFactory { get; } - static DefaultLogger() - { - LoggerFactory = new LoggerFactory(); - } - } + static DefaultLogger() => LoggerFactory = new LoggerFactory(); } diff --git a/Yllibed.HttpServer/Logging/LogExtensions.cs b/Yllibed.HttpServer/Logging/LogExtensions.cs index 36d0b3d..40d804a 100644 --- a/Yllibed.HttpServer/Logging/LogExtensions.cs +++ b/Yllibed.HttpServer/Logging/LogExtensions.cs @@ -1,20 +1,14 @@ -using System; -using System.Globalization; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; -namespace Yllibed.Framework.Logging +namespace Yllibed.Framework.Logging; + +public static class LogExtensions { - public static class LogExtensions + // Helper class to cache the creation of the logger instance + private static class LoggerOfT { - // Helper class to cache the creation of the logger instance - private static class LoggerOfT - { - internal static readonly ILogger LoggerInstance = DefaultLogger.LoggerFactory.CreateLogger(typeof(T).FullName!); - } - - public static ILogger Log(this T source) - { - return LoggerOfT.LoggerInstance; - } + internal static readonly ILogger LoggerInstance = DefaultLogger.LoggerFactory.CreateLogger(typeof(T).FullName!); } -} \ No newline at end of file + + public static ILogger Log(this T source) => LoggerOfT.LoggerInstance; +} diff --git a/Yllibed.HttpServer/README.md b/Yllibed.HttpServer/README.md index da3b49d..28997f0 100644 --- a/Yllibed.HttpServer/README.md +++ b/Yllibed.HttpServer/README.md @@ -1,17 +1,77 @@ # Yllibed Http Server -This is a versatile http server designed to be used any applications which need to expose a simple web server. +A versatile, lightweight HTTP server for .NET applications. Self-contained with no dependencies on ASP.NET or other frameworks. -There is no dependencies on ASP.NET or other frameworks, it is self-contained. +## Quick Start -Common use cases are: -* Return URL for OAuth2 authentication using external browser -* Remote diagnostics/monitoring on your app -* Building a headless Windows IoT app (for SSDP discovery or simply end-user configuration) -* Any other use case where you need to expose a simple web server +```csharp +// Recommended: Use dynamic port assignment +var server = new Server(); // Port 0 by default - no conflicts! +server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); +var (uri4, uri6) = server.Start(); +Console.WriteLine($"Server running at: {uri4}"); +``` -Limitations: -* There is no support for HTTP 2.0+ (yet) or WebSockets -* There is no support for HTTPS (TLS) -* This HTTP server is not designed for performance or high capacity -* It's perfect for small applications, or small need +Or with a fixed port: +```csharp +var server = new Server(8080); +server.RegisterHandler(new StaticHandler("/", "text/plain", "Hello, world!")); +var (uri4, uri6) = server.Start(); +``` + +## Common Use Cases +* OAuth2 return URL endpoints +* Remote diagnostics/monitoring +* IoT device configuration interfaces +* Simple REST API endpoints + +## Configuration + +**Dynamic Port Assignment (Recommended):** Using port `0` automatically assigns an available port, preventing conflicts—perfect for testing, microservices, and team development. + +```csharp +// ✅ Recommended approach +var server = new Server(); // Dynamic port +var (uri4, uri6) = server.Start(); +var actualPort = new Uri(uri4).Port; // Get the assigned port +``` + +For advanced configuration, use `ServerOptions`: + +```csharp +var serverOptions = new ServerOptions +{ + Port = 0, // Dynamic port (recommended) + Hostname4 = "127.0.0.1", + Hostname6 = "::1", + BindAddress4 = IPAddress.Any // Listen on all interfaces +}; +var server = new Server(serverOptions); +``` + +## Dependency Injection + +Works with Microsoft.Extensions.DependencyInjection: + +```csharp +// Option 1: Extension method (cleanest) - uses dynamic ports by default +services.AddYllibedHttpServer(); // Zero configuration, no conflicts! + +// Or configure explicitly: +services.AddYllibedHttpServer(opts => +{ + opts.Port = 0; // Dynamic port (recommended) + opts.Hostname4 = "127.0.0.1"; +}); + +// Option 2: Configure + AddSingleton +services.Configure(opts => { opts.Port = 0; }); +services.AddSingleton(); // Auto-selects IOptions<> constructor +``` + +## Limitations +* HTTP/1.1 only (no HTTP/2+ or WebSockets) +* No HTTPS/TLS support +* Designed for small-scale applications + +For more examples and advanced usage, visit the [GitHub repository](https://github.com/carldebilly/Yllibed.HttpServer). diff --git a/Yllibed.HttpServer/Server.HttpServerRequest.cs b/Yllibed.HttpServer/Server.HttpServerRequest.cs index 893e879..3355bf8 100644 --- a/Yllibed.HttpServer/Server.HttpServerRequest.cs +++ b/Yllibed.HttpServer/Server.HttpServerRequest.cs @@ -22,8 +22,7 @@ public sealed partial class Server private sealed class HttpServerRequest : IDisposable, IHttpServerRequest { private readonly TcpClient _tcpClient; - private readonly CancellationToken _ct; - private Func _onReady; + private readonly Func _onReady; private readonly Action _onCompletedOrDisconnected; private static long NextRequestId = 0; @@ -39,11 +38,10 @@ internal HttpServerRequest( _tcpClient = tcpClient; _onReady = onReady; _onCompletedOrDisconnected = onCompletedOrDisconnected; - _ct = ct; Port = port; - _ = ProcessConnection(_ct); + _ = ProcessConnection(ct); } private async Task ProcessConnection(CancellationToken ct) @@ -89,10 +87,10 @@ private async Task ProcessRequest(CancellationToken ct, Stream stream) { var encoding = GetRequestEncoding(); - using var requestReader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, (int) BufferSize, leaveOpen: true); + using var requestReader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, (int)BufferSize, leaveOpen: true); var requestLine = await requestReader.ReadLineAsync().ConfigureAwait(true); var requestLineParts = requestLine?.Split(' '); - if (requestLineParts is not {Length: 3}) + if (requestLineParts is not { Length: 3 }) { throw new InvalidOperationException("Invalid request line"); } @@ -146,7 +144,7 @@ private async Task ProcessRequest(CancellationToken ct, Stream stream) private Encoding GetRequestEncoding() { if (ContentType is { Length: > 0 } - && ContentTypeCharsetRegex.Match(ContentType) is { Success: true } match) + && ContentTypeCharsetRegex.Match(ContentType) is { Success: true } match) { try { @@ -163,7 +161,7 @@ private Encoding GetRequestEncoding() private async Task ProcessResponse(CancellationToken ct, NetworkStream stream) { - using var responseWriter = new StreamWriter(stream, Utf8, (int) BufferSize, leaveOpen: true); + using var responseWriter = new StreamWriter(stream, Utf8, (int)BufferSize, leaveOpen: true); responseWriter.NewLine = "\r\n"; await ProcessResponseHeader(responseWriter).ConfigureAwait(true); diff --git a/Yllibed.HttpServer/Server.cs b/Yllibed.HttpServer/Server.cs index 11655a3..2c41459 100644 --- a/Yllibed.HttpServer/Server.cs +++ b/Yllibed.HttpServer/Server.cs @@ -24,6 +24,7 @@ public sealed partial class Server : IHttpServer, IDisposable { private const uint BufferSize = 8192; private readonly ushort _localPort; + private readonly ServerOptions _options; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); @@ -46,7 +47,38 @@ public sealed partial class Server : IHttpServer, IDisposable /// Not specifying a port will use a dynamic port assigned by the system. /// /// Port number to listen to. 0=dynamic. - public Server(ushort localPort = 0) => _localPort = localPort; + public Server(ushort localPort) + { + _localPort = localPort; + _options = new ServerOptions { Port = localPort }; + } + + /// + /// Create a new server with default settings (dynamic port). + /// + public Server() : this(localPort: 0) + { + } + + /// + /// Create a new server with explicit options. + /// + /// Server options. + public Server(ServerOptions options) + { + _options = options ?? new ServerOptions(); + _localPort = _options.Port; + } + + /// + /// Create a new server with options resolved via Microsoft.Extensions.Options. + /// + /// A wrapper around . + [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] + public Server(Microsoft.Extensions.Options.IOptions options) + : this(options?.Value ?? new ServerOptions()) + { + } private async Task HandleRequest(CancellationToken ct, HttpServerRequest request) { @@ -77,7 +109,10 @@ private async Task HandleRequest(CancellationToken ct, HttpServerRequest request private (Uri Uri4, Uri Uri6) Initialize() { - var tcpListener4 = new TcpListener(IPAddress.Any, _localPort); + var bind4 = _options?.BindAddress4 ?? IPAddress.Any; + var bind6 = _options?.BindAddress6 ?? IPAddress.IPv6Any; + + var tcpListener4 = new TcpListener(bind4, _localPort); tcpListener4.Start(); // Tentatively use the same port for IPv6 @@ -86,11 +121,11 @@ private async Task HandleRequest(CancellationToken ct, HttpServerRequest request ? localPort4 : _localPort; - var tcpListener6 = new TcpListener(IPAddress.IPv6Any, localPort6); + var tcpListener6 = new TcpListener(bind6, localPort6); tcpListener6.Start(); - const string hostname4 = "127.0.0.1"; - const string hostname6 = "::1"; + var hostname4 = _options?.Hostname4 ?? "127.0.0.1"; + var hostname6 = _options?.Hostname6 ?? "::1"; var uri4 = new Uri($"http://{hostname4}:{localPort4}"); var uri6 = new Uri($"http://[{hostname6}]:{localPort6}"); diff --git a/Yllibed.HttpServer/ServerOptions.cs b/Yllibed.HttpServer/ServerOptions.cs new file mode 100644 index 0000000..77a74ea --- /dev/null +++ b/Yllibed.HttpServer/ServerOptions.cs @@ -0,0 +1,43 @@ +using System.Net; + +namespace Yllibed.HttpServer; + +/// +/// Configuration options for . +/// +public sealed class ServerOptions +{ + /// + /// Port number to listen to. 0 = dynamic. + /// + public ushort Port { get; set; } + + /// + /// IPv4 address to bind the listener to. Defaults to . + /// + public IPAddress BindAddress4 { get; set; } + + /// + /// IPv6 address to bind the listener to. Defaults to . + /// + public IPAddress BindAddress6 { get; set; } + + /// + /// Hostname used to compose the public IPv4 URI. Defaults to loopback "127.0.0.1". + /// + public string Hostname4 { get; set; } + + /// + /// Hostname used to compose the public IPv6 URI. Defaults to loopback "::1". + /// + public string Hostname6 { get; set; } + + public ServerOptions() + { + Port = 0; + BindAddress4 = IPAddress.Any; + BindAddress6 = IPAddress.IPv6Any; + Hostname4 = "127.0.0.1"; + Hostname6 = "::1"; + } +} diff --git a/Yllibed.HttpServer/Yllibed.HttpServer.csproj b/Yllibed.HttpServer/Yllibed.HttpServer.csproj index 7f12bd8..c07d317 100644 --- a/Yllibed.HttpServer/Yllibed.HttpServer.csproj +++ b/Yllibed.HttpServer/Yllibed.HttpServer.csproj @@ -5,10 +5,13 @@ true README.md + + $(InternalsVisibleTo);Yllibed.HttpServer.Json + @@ -17,4 +20,4 @@ - \ No newline at end of file +