/// Specifies the compression method used to compress a message on
@@ -12,18 +12,18 @@
public enum CompressionMethod : byte
{
///
- /// Specifies non compression.
+ /// Specifies no compression.
///
None,
///
- /// Specifies DEFLATE.
+ /// Specifies "Deflate" compression.
///
Deflate,
///
- /// Specifies GZIP.
+ /// Specifies GZip compression.
///
Gzip,
}
-}
+}
\ No newline at end of file
diff --git a/src/EmbedIO/CompressionMethodNames.cs b/src/EmbedIO/CompressionMethodNames.cs
new file mode 100644
index 000000000..3511b96f3
--- /dev/null
+++ b/src/EmbedIO/CompressionMethodNames.cs
@@ -0,0 +1,27 @@
+namespace EmbedIO
+{
+ ///
+ /// Exposes constants for possible values of the Content-Encoding HTTP header.
+ ///
+ ///
+ public static class CompressionMethodNames
+ {
+ ///
+ /// Specifies no compression.
+ ///
+ ///
+ public const string None = "identity";
+
+ ///
+ /// Specifies the "Deflate" compression method.
+ ///
+ ///
+ public const string Deflate = "deflate";
+
+ ///
+ /// Specifies the GZip compression method.
+ ///
+ ///
+ public const string Gzip = "gzip";
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Cors/CorsModule.cs b/src/EmbedIO/Cors/CorsModule.cs
new file mode 100644
index 000000000..ee9bafdb8
--- /dev/null
+++ b/src/EmbedIO/Cors/CorsModule.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Cors
+{
+ ///
+ /// Cross-origin resource sharing (CORS) control Module.
+ /// CORS is a mechanism that allows restricted resources (e.g. fonts)
+ /// on a web page to be requested from another domain outside the domain from which the resource originated.
+ ///
+ public class CorsModule : WebModuleBase
+ {
+ ///
+ /// A string meaning "All" in CORS headers.
+ ///
+ public const string All = "*";
+
+ private readonly string _origins;
+ private readonly string _headers;
+ private readonly string _methods;
+ private readonly string[] _validOrigins;
+ private readonly string[] _validMethods;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The base route.
+ /// The valid origins. The default is (* ).
+ /// The valid headers. The default is (* ).
+ /// The valid methods. The default is (* ).
+ ///
+ /// origins
+ /// or
+ /// headers
+ /// or
+ /// methods
+ ///
+ public CorsModule(
+ string baseRoute,
+ string origins = All,
+ string headers = All,
+ string methods = All)
+ : base(baseRoute)
+ {
+ _origins = origins ?? throw new ArgumentNullException(nameof(origins));
+ _headers = headers ?? throw new ArgumentNullException(nameof(headers));
+ _methods = methods ?? throw new ArgumentNullException(nameof(methods));
+
+ _validOrigins =
+ origins.ToLowerInvariant()
+ .SplitByComma(StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim())
+ .ToArray();
+ _validMethods =
+ methods.ToLowerInvariant()
+ .SplitByComma(StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim())
+ .ToArray();
+ }
+
+ ///
+ public override bool IsFinalHandler => false;
+
+ ///
+ protected override Task OnRequestAsync(IHttpContext context)
+ {
+ var isOptions = context.Request.HttpVerb == HttpVerbs.Options;
+
+ // If we allow all we don't need to filter
+ if (_origins == All && _headers == All && _methods == All)
+ {
+ context.Response.Headers.Set(HttpHeaderNames.AccessControlAllowOrigin, All);
+
+ if (isOptions)
+ {
+ ValidateHttpOptions(context);
+ context.SetHandled();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ var currentOrigin = context.Request.Headers[HttpHeaderNames.Origin];
+
+ if (string.IsNullOrWhiteSpace(currentOrigin) && context.Request.IsLocal)
+ return Task.CompletedTask;
+
+ if (_origins == All)
+ return Task.CompletedTask;
+
+ if (_validOrigins.Contains(currentOrigin))
+ {
+ context.Response.Headers.Set(HttpHeaderNames.AccessControlAllowOrigin, currentOrigin);
+
+ if (isOptions)
+ {
+ ValidateHttpOptions(context);
+ context.SetHandled();
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void ValidateHttpOptions(IHttpContext context)
+ {
+ var requestHeadersHeader = context.Request.Headers[HttpHeaderNames.AccessControlRequestHeaders];
+ if (!string.IsNullOrWhiteSpace(requestHeadersHeader))
+ {
+ // TODO: Remove unwanted headers from request
+ context.Response.Headers.Set(HttpHeaderNames.AccessControlAllowHeaders, requestHeadersHeader);
+ }
+
+ var requestMethodHeader = context.Request.Headers[HttpHeaderNames.AccessControlRequestMethod];
+ if (string.IsNullOrWhiteSpace(requestMethodHeader))
+ return;
+
+ var currentMethods = requestMethodHeader.ToLowerInvariant()
+ .SplitByComma(StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim());
+
+ if (_methods != All && !currentMethods.Any(_validMethods.Contains))
+ throw HttpException.BadRequest();
+
+ context.Response.Headers.Set(HttpHeaderNames.AccessControlAllowMethods, requestMethodHeader);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/Unosquare.Labs.EmbedIO.csproj b/src/EmbedIO/EmbedIO.csproj
similarity index 80%
rename from src/Unosquare.Labs.EmbedIO/Unosquare.Labs.EmbedIO.csproj
rename to src/EmbedIO/EmbedIO.csproj
index 4b8ca3bd3..b5943d78e 100644
--- a/src/Unosquare.Labs.EmbedIO/Unosquare.Labs.EmbedIO.csproj
+++ b/src/EmbedIO/EmbedIO.csproj
@@ -4,17 +4,17 @@
A tiny, cross-platform, module based, MIT-licensed web server. Supporting NET Framework, Net Core, and Mono.
Copyright © Unosquare 2013-2019
EmbedIO Web Server
- Unosquare
+ Unosquare, and Contributors to EmbedIO
netstandard2.0
true
- Unosquare.Labs.EmbedIO
+ EmbedIO
EmbedIO
..\..\StyleCop.Analyzers.ruleset
Full
- 2.9.1
+ 3.0.0
EmbedIO
Unosquare
- https://raw.githubusercontent.com/unosquare/embedio/master/LICENSE
+ LICENSE
http://unosquare.github.io/embedio
https://unosquare.github.io/embedio/embedio.png
https://github.com/unosquare/embedio/
@@ -23,6 +23,10 @@
7.3
+
+
+
+
all
@@ -32,7 +36,7 @@
all
runtime; build; native; contentfiles; analyzers
-
+
diff --git a/src/EmbedIO/EmbedIOInternalErrorException.cs b/src/EmbedIO/EmbedIOInternalErrorException.cs
new file mode 100644
index 000000000..439900b32
--- /dev/null
+++ b/src/EmbedIO/EmbedIOInternalErrorException.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Runtime.Serialization;
+
+/*
+ * NOTE TO CONTRIBUTORS:
+ *
+ * Never use this exception directly.
+ * Use the methods in EmbedIO.Internal.SelfCheck instead.
+ */
+
+namespace EmbedIO
+{
+#pragma warning disable SA1642 // Constructor summary documentation should begin with standard text
+ ///
+ /// The exception that is thrown by EmbedIO's internal diagnostic checks to signal a condition
+ /// most probably caused by an error in EmbedIO.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ [Serializable]
+ public class EmbedIOInternalErrorException : Exception
+ {
+ ///
+ /// Initializes a new instance of the class.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ public EmbedIOInternalErrorException()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ /// The message that describes the error.
+ public EmbedIOInternalErrorException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception,
+ /// or if no inner exception is specified.
+ public EmbedIOInternalErrorException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ /// The that holds the serialized object data about the exception being thrown.
+ /// The that contains contextual information about the source or destination.
+ protected EmbedIOInternalErrorException(SerializationInfo info, StreamingContext context)
+ : base(info, context)
+ {
+ }
+ }
+#pragma warning restore SA1642
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ExceptionHandler.cs b/src/EmbedIO/ExceptionHandler.cs
new file mode 100644
index 000000000..ea10dd479
--- /dev/null
+++ b/src/EmbedIO/ExceptionHandler.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Net;
+using System.Runtime.ExceptionServices;
+using System.Threading.Tasks;
+using System.Web;
+using Swan;
+using Swan.Logging;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides standard handlers for unhandled exceptions at both module and server level.
+ ///
+ ///
+ ///
+ public static class ExceptionHandler
+ {
+ ///
+ /// The name of the response header used by the
+ /// handler to transmit the type of the exception to the client.
+ ///
+ public const string ExceptionTypeHeaderName = "X-Exception-Type";
+
+ ///
+ /// The name of the response header used by the
+ /// handler to transmit the message of the exception to the client.
+ ///
+ public const string ExceptionMessageHeaderName = "X-Exception-Message";
+
+ ///
+ /// Gets or sets the contact information to include in exception responses.
+ ///
+ public static string ContactInformation { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to include stack traces
+ /// in exception responses.
+ ///
+ public static bool IncludeStackTraces { get; set; }
+
+ ///
+ /// Gets the default handler used by .
+ /// This is the same as .
+ ///
+ public static ExceptionHandlerCallback Default { get; } = HtmlResponse;
+
+ ///
+ /// Sends an empty 500 Internal Server Error response.
+ ///
+ /// A interface representing the context of the request.
+ /// The unhandled exception.
+ /// A representing the ongoing operation.
+ public static Task EmptyResponse(IHttpContext context, Exception exception)
+ {
+ context.Response.SetEmptyResponse((int) HttpStatusCode.InternalServerError);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Sends an empty 500 Internal Server Error response,
+ /// with the following additional headers:
+ ///
+ ///
+ /// Header
+ /// Value
+ ///
+ /// -
+ ///
X-Exception-Type
+ /// The name (without namespace) of the type of exception that was thrown.
+ ///
+ /// -
+ ///
X-Exception-Message
+ /// The Message property of the exception.
+ ///
+ ///
+ /// The aforementioned header names are available as the and
+ /// properties, respectively.
+ ///
+ /// A interface representing the context of the request.
+ /// The unhandled exception.
+ /// A representing the ongoing operation.
+ public static Task EmptyResponseWithHeaders(IHttpContext context, Exception exception)
+ {
+ context.Response.SetEmptyResponse((int)HttpStatusCode.InternalServerError);
+ context.Response.Headers[ExceptionTypeHeaderName] = Uri.EscapeDataString(exception.GetType().Name);
+ context.Response.Headers[ExceptionMessageHeaderName] = Uri.EscapeDataString(exception.Message);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Sends a 500 Internal Server Error response with a HTML payload
+ /// briefly describing the error, including contact information and/or a stack trace
+ /// if specified via the and
+ /// properties, respectively.
+ ///
+ /// A interface representing the context of the request.
+ /// The unhandled exception.
+ /// A representing the ongoing operation.
+ public static Task HtmlResponse(IHttpContext context, Exception exception)
+ => context.SendStandardHtmlAsync(
+ (int)HttpStatusCode.InternalServerError,
+ text => {
+ text.Write("The server has encountered an error and was not able to process your request.
");
+ text.Write("Please contact the server administrator");
+
+ if (!string.IsNullOrEmpty(ContactInformation))
+ text.Write(" ({0})", HttpUtility.HtmlEncode(ContactInformation));
+
+ text.Write(", informing them of the time this error occurred and the action(s) you performed that resulted in this error.
");
+ text.Write("The following information may help them in finding out what happened and restoring full functionality.
");
+ text.Write(
+ "Exception type: {0}
Message: {1}",
+ HttpUtility.HtmlEncode(exception.GetType().FullName ?? ""),
+ HttpUtility.HtmlEncode(exception.Message));
+
+ if (IncludeStackTraces)
+ {
+ text.Write(
+ "
Stack trace:
{0} ",
+ HttpUtility.HtmlEncode(exception.StackTrace));
+ }
+ });
+
+ internal static async Task Handle(string logSource, IHttpContext context, Exception exception, ExceptionHandlerCallback handler)
+ {
+ if (handler == null)
+ {
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ return;
+ }
+
+ exception.Log(logSource, $"[{context.Id}] Unhandled exception.");
+
+ try
+ {
+ context.Response.SetEmptyResponse((int)HttpStatusCode.InternalServerError);
+ context.Response.DisableCaching();
+ await handler(context, exception)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (HttpListenerException)
+ {
+ throw;
+ }
+ catch (Exception exception2)
+ {
+ exception2.Log(logSource, $"[{context.Id}] Unhandled exception while handling exception.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ExceptionHandlerCallback.cs b/src/EmbedIO/ExceptionHandlerCallback.cs
new file mode 100644
index 000000000..e29fcb0f9
--- /dev/null
+++ b/src/EmbedIO/ExceptionHandlerCallback.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// A callback used to provide information about an unhandled exception occurred while processing a request.
+ ///
+ /// A interface representing the context of the request.
+ /// The unhandled exception.
+ /// A representing the ongoing operation.
+ ///
+ /// When this delegate is called, the response's status code has already been set to
+ /// .
+ /// Any exception thrown by a handler (even a HTTP exception) will go unhandled: the web server
+ /// will not crash, but processing of the request will be aborted, and the response will be flushed as-is.
+ /// In other words, it is not a good ides to throw HttpException.NotFound() (or similar)
+ /// from a handler.
+ ///
+ public delegate Task ExceptionHandlerCallback(IHttpContext context, Exception exception);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/DirectoryLister.cs b/src/EmbedIO/Files/DirectoryLister.cs
new file mode 100644
index 000000000..d99ee8950
--- /dev/null
+++ b/src/EmbedIO/Files/DirectoryLister.cs
@@ -0,0 +1,20 @@
+using EmbedIO.Files.Internal;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides standard directory listers for .
+ ///
+ ///
+ public static class DirectoryLister
+ {
+ ///
+ /// Gets an interface
+ /// that produces a HTML listing of a directory.
+ /// The output of the returned directory lister
+ /// is the same as a directory listing obtained
+ /// by EmbedIO version 2.
+ ///
+ public static IDirectoryLister Html => HtmlDirectoryLister.Instance;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileCache.Section.cs b/src/EmbedIO/Files/FileCache.Section.cs
new file mode 100644
index 000000000..66497548a
--- /dev/null
+++ b/src/EmbedIO/Files/FileCache.Section.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using EmbedIO.Files.Internal;
+
+namespace EmbedIO.Files
+{
+ public sealed partial class FileCache
+ {
+ internal class Section
+ {
+ private readonly object _syncRoot = new object();
+ private readonly Dictionary _items = new Dictionary(StringComparer.Ordinal);
+ private long _totalSize;
+ private string _oldestKey;
+ private string _newestKey;
+
+ public void Clear()
+ {
+ lock (_syncRoot)
+ {
+ ClearCore();
+ }
+ }
+
+ public void Add(string path, FileCacheItem item)
+ {
+ lock (_syncRoot)
+ {
+ AddItemCore(path, item);
+ }
+ }
+
+ public void Remove(string path)
+ {
+ lock (_syncRoot)
+ {
+ RemoveItemCore(path);
+ }
+ }
+
+ public bool TryGet(string path, out FileCacheItem item)
+ {
+ lock (_syncRoot)
+ {
+ if (!_items.TryGetValue(path, out item))
+ return false;
+
+ RefreshItemCore(path, item);
+ return true;
+ }
+ }
+
+ internal long GetLeastRecentUseTime()
+ {
+ lock (_syncRoot)
+ {
+ return _oldestKey == null ? long.MaxValue : _items[_oldestKey].LastUsedAt;
+ }
+ }
+
+ // Removes least recently used item.
+ // Returns size of removed item.
+ internal long RemoveLeastRecentItem()
+ {
+ lock (_syncRoot)
+ {
+ return RemoveLeastRecentItemCore();
+ }
+ }
+
+ internal long GetTotalSize()
+ {
+ lock (_syncRoot)
+ {
+ return _totalSize;
+ }
+ }
+
+ internal void UpdateTotalSize(long delta)
+ {
+ lock (_syncRoot)
+ {
+ _totalSize += delta;
+ }
+ }
+
+ private void ClearCore()
+ {
+ _items.Clear();
+ _totalSize = 0;
+ _oldestKey = null;
+ _newestKey = null;
+ }
+
+ // Adds an item as most recently used.
+ private void AddItemCore(string path, FileCacheItem item)
+ {
+ item.PreviousKey = _newestKey;
+ item.NextKey = null;
+ item.LastUsedAt = TimeBase.ElapsedTicks;
+
+ if (_newestKey != null)
+ _items[_newestKey].NextKey = path;
+
+ _newestKey = path;
+
+ _items[path] = item;
+ _totalSize += item.SizeInCache;
+ }
+
+ // Removes an item.
+ private void RemoveItemCore(string path)
+ {
+ if (!_items.TryGetValue(path, out var item))
+ return;
+
+ if (_oldestKey == path)
+ _oldestKey = item.NextKey;
+
+ if (_newestKey == path)
+ _newestKey = item.PreviousKey;
+
+ if (item.PreviousKey != null)
+ _items[item.PreviousKey].NextKey = item.NextKey;
+
+ if (item.NextKey != null)
+ _items[item.NextKey].PreviousKey = item.PreviousKey;
+
+ item.PreviousKey = null;
+ item.NextKey = null;
+
+ _items.Remove(path);
+ _totalSize -= item.SizeInCache;
+ }
+
+ // Removes the least recently used item.
+ // returns size of removed item.
+ private long RemoveLeastRecentItemCore()
+ {
+ var path = _oldestKey;
+ if (path == null)
+ return 0;
+
+ var item = _items[path];
+
+ if ((_oldestKey = item.NextKey) != null)
+ _items[_oldestKey].PreviousKey = null;
+
+ if (_newestKey == path)
+ _newestKey = null;
+
+ item.PreviousKey = null;
+ item.NextKey = null;
+
+ _items.Remove(path);
+ _totalSize -= item.SizeInCache;
+ return item.SizeInCache;
+ }
+
+ // Moves an item to most recently used.
+ private void RefreshItemCore(string path, FileCacheItem item)
+ {
+ item.LastUsedAt = TimeBase.ElapsedTicks;
+
+ if (_newestKey == path)
+ return;
+
+ if (_oldestKey == path)
+ _oldestKey = item.NextKey;
+
+ if (item.PreviousKey != null)
+ _items[item.PreviousKey].NextKey = item.NextKey;
+
+ if (item.NextKey != null)
+ _items[item.NextKey].PreviousKey = item.PreviousKey;
+
+ item.PreviousKey = _newestKey;
+ item.NextKey = null;
+
+ _items[_newestKey].NextKey = path;
+ _newestKey = path;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileCache.cs b/src/EmbedIO/Files/FileCache.cs
new file mode 100644
index 000000000..3a9602104
--- /dev/null
+++ b/src/EmbedIO/Files/FileCache.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using Swan.Threading;
+using Swan.Logging;
+
+namespace EmbedIO.Files
+{
+#pragma warning disable CA1001 // Type owns disposable field '_cleaner' but is not disposable - _cleaner has its own dispose semantics.
+ ///
+ /// A cache where one or more instances of can store hashes and file contents.
+ ///
+ public sealed partial class FileCache
+#pragma warning restore CA1001
+ {
+ ///
+ /// The default value for the property.
+ ///
+ public const int DefaultMaxSizeKb = 10240;
+
+ ///
+ /// The default value for the property.
+ ///
+ public const int DefaultMaxFileSizeKb = 200;
+
+ private static readonly Stopwatch TimeBase = Stopwatch.StartNew();
+
+ private static readonly object DefaultSyncRoot = new object();
+ private static FileCache _defaultInstance;
+
+ private readonly ConcurrentDictionary _sections = new ConcurrentDictionary(StringComparer.Ordinal);
+ private int _sectionCount; // Because ConcurrentDictionary<,>.Count is locking.
+ private int _maxSizeKb = DefaultMaxSizeKb;
+ private int _maxFileSizeKb = DefaultMaxFileSizeKb;
+ private PeriodicTask _cleaner;
+
+ ///
+ /// Gets the default instance used by .
+ ///
+ public static FileCache Default
+ {
+ get
+ {
+ if (_defaultInstance != null)
+ return _defaultInstance;
+
+ lock (DefaultSyncRoot)
+ {
+ if (_defaultInstance == null)
+ _defaultInstance = new FileCache();
+ }
+
+ return _defaultInstance;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum total size of cached data in kilobytes (1 kilobyte = 1024 bytes).
+ /// The default value for this property is stored in the constant field.
+ /// Setting this property to a value less lower han 1 has the same effect as setting it to 1.
+ ///
+ public int MaxSizeKb
+ {
+ get => _maxSizeKb;
+ set => _maxSizeKb = Math.Max(value, 1);
+ }
+
+ ///
+ /// Gets or sets the maximum size of a single cached file in kilobytes (1 kilobyte = 1024 bytes).
+ /// A single file's contents may be present in a cache more than once, if the file
+ /// is requested with different Accept-Encoding request headers. This property acts as a threshold
+ /// for the uncompressed size of a file.
+ /// The default value for this property is stored in the constant field.
+ /// Setting this property to a value lower than 0 has the same effect as setting it to 0, in fact
+ /// completely disabling the caching of file contents for this cache.
+ /// This property cannot be set to a value higher than 2097151; in other words, it is not possible
+ /// to cache files bigger than two Gigabytes (1 Gigabyte = 1048576 kilobytes) minus 1 kilobyte.
+ ///
+ public int MaxFileSizeKb
+ {
+ get => _maxFileSizeKb;
+ set => _maxFileSizeKb = Math.Min(Math.Max(value, 0), 2097151);
+ }
+
+ // Cast as IDictionary because we WANT an exception to be thrown if the name exists.
+ // It would mean that something is very, very wrong.
+ internal Section AddSection(string name)
+ {
+ var section = new Section();
+ (_sections as IDictionary).Add(name, section);
+
+ if (Interlocked.Increment(ref _sectionCount) == 1)
+ _cleaner = new PeriodicTask(TimeSpan.FromMinutes(1), CheckMaxSize);
+
+ return section;
+ }
+
+ internal void RemoveSection(string name)
+ {
+ _sections.TryRemove(name, out _);
+
+ if (Interlocked.Decrement(ref _sectionCount) == 0)
+ {
+ _cleaner.Dispose();
+ _cleaner = null;
+ }
+ }
+
+ private async Task CheckMaxSize(CancellationToken cancellationToken)
+ {
+ var timeKeeper = new TimeKeeper();
+ var maxSizeKb = _maxSizeKb;
+ var initialSizeKb = ComputeTotalSize() / 1024L;
+ if (initialSizeKb <= maxSizeKb)
+ {
+ $"Total size = {initialSizeKb}/{_maxSizeKb}kb, not purging.".Info(nameof(FileCache));
+ return;
+ }
+
+ $"Total size = {initialSizeKb}/{_maxSizeKb}kb, purging...".Debug(nameof(FileCache));
+
+ var removedCount = 0;
+ var removedSize = 0L;
+ var totalSizeKb = initialSizeKb;
+ var threshold = 973L * maxSizeKb / 1024L; // About 95% of maximum allowed size
+ while (totalSizeKb > threshold)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ var section = GetSectionWithLeastRecentItem();
+ if (section == null)
+ return;
+
+ removedSize += section.RemoveLeastRecentItem();
+ removedCount++;
+
+ await Task.Yield();
+
+ totalSizeKb = ComputeTotalSize() / 1024L;
+ }
+
+ $"Purge completed in {timeKeeper.ElapsedTime}ms: removed {removedCount} items ({removedSize / 1024L}kb). Total size is now {totalSizeKb}kb."
+ .Info(nameof(FileCache));
+ }
+
+ // Enumerate key / value pairs because the Keys and Values property
+ // of ConcurrentDictionary<,> have snapshot semantics,
+ // while GetEnumerator enumerates without locking.
+ private long ComputeTotalSize()
+ => _sections.Sum(pair => pair.Value.GetTotalSize());
+
+ private Section GetSectionWithLeastRecentItem()
+ {
+ Section result = null;
+ var earliestTime = long.MaxValue;
+ foreach (var pair in _sections)
+ {
+ var section = pair.Value;
+ var time = section.GetLeastRecentUseTime();
+
+ if (time < earliestTime)
+ {
+ result = section;
+ earliestTime = time;
+ }
+ }
+
+ return result;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileModule.cs b/src/EmbedIO/Files/FileModule.cs
new file mode 100644
index 000000000..4c9d371ab
--- /dev/null
+++ b/src/EmbedIO/Files/FileModule.cs
@@ -0,0 +1,649 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Files.Internal;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// A module serving files and directory listings from a .
+ ///
+ ///
+ public class FileModule : WebModuleBase, IDisposable, IMimeTypeCustomizer
+ {
+ ///
+ /// Default value for .
+ ///
+ public const string DefaultDocumentName = "index.html";
+
+ private readonly string _cacheSectionName = UniqueIdGenerator.GetNext();
+ private readonly MimeTypeCustomizer _mimeTypeCustomizer = new MimeTypeCustomizer();
+ private readonly ConcurrentDictionary _mappingCache;
+
+ private FileCache _cache = FileCache.Default;
+ private bool _contentCaching = true;
+ private string _defaultDocument = DefaultDocumentName;
+ private string _defaultExtension;
+ private IDirectoryLister _directoryLister;
+ private FileRequestHandlerCallback _onMappingFailed = FileRequestHandler.ThrowNotFound;
+ private FileRequestHandlerCallback _onDirectoryNotListable = FileRequestHandler.ThrowUnauthorized;
+ private FileRequestHandlerCallback _onMethodNotAllowed = FileRequestHandler.ThrowMethodNotAllowed;
+
+ private FileCache.Section _cacheSection;
+
+ ///
+ /// Initializes a new instance of the class,
+ /// using the specified cache.
+ ///
+ /// The base route.
+ /// An interface that provides access
+ /// to actual files and directories.
+ /// is .
+ public FileModule(string baseRoute, IFileProvider provider)
+ : base(baseRoute)
+ {
+ Provider = Validate.NotNull(nameof(provider), provider);
+ _mappingCache = Provider.IsImmutable
+ ? new ConcurrentDictionary()
+ : null;
+ }
+
+ ///
+ /// Finalizes an instance of the class.
+ ///
+ ~FileModule()
+ {
+ Dispose(false);
+ }
+
+ ///
+ public override bool IsFinalHandler => true;
+
+ ///
+ /// Gets the interface that provides access
+ /// to actual files and directories served by this module.
+ ///
+ public IFileProvider Provider { get; }
+
+ ///
+ /// Gets or sets the used by this module to store hashes and,
+ /// optionally, file contents and rendered directory listings.
+ ///
+ /// The module's configuration is locked.
+ /// This property is being set to .
+ public FileCache Cache
+ {
+ get => _cache;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _cache = Validate.NotNull(nameof(value), value);
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether this module caches the contents of files
+ /// and directory listings.
+ /// Note that the actual representations of files are stored in ;
+ /// thus, for example, if a file is always requested with an Accept-Encoding of gzip ,
+ /// only the gzipped contents of the file will be cached.
+ ///
+ /// The module's configuration is locked.
+ public bool ContentCaching
+ {
+ get => _contentCaching;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _contentCaching = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the name of the default document served, if it exists, instead of a directory listing
+ /// when the path of a requested URL maps to a directory.
+ /// The default value for this property is the constant.
+ ///
+ /// The module's configuration is locked.
+ public string DefaultDocument
+ {
+ get => _defaultDocument;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _defaultDocument = string.IsNullOrEmpty(value) ? null : value;
+ }
+ }
+
+ ///
+ /// Gets or sets the default extension appended to requested URL paths that do not map
+ /// to any file or directory. Defaults to .
+ ///
+ /// The module's configuration is locked.
+ /// This property is being set to a non- ,
+ /// non-empty string that does not start with a period (. ).
+ public string DefaultExtension
+ {
+ get => _defaultExtension;
+ set
+ {
+ EnsureConfigurationNotLocked();
+
+ if (string.IsNullOrEmpty(value))
+ {
+ _defaultExtension = null;
+ }
+ else if (value[0] != '.')
+ {
+ throw new ArgumentException("Default extension does not start with a period.", nameof(value));
+ }
+ else
+ {
+ _defaultExtension = value;
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the interface used to generate
+ /// directory listing in this module.
+ /// A value of (the default) disables the generation
+ /// of directory listings.
+ ///
+ /// The module's configuration is locked.
+ public IDirectoryLister DirectoryLister
+ {
+ get => _directoryLister;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _directoryLister = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a that is called whenever
+ /// the requested URL path could not be mapped to any file or directory.
+ /// The default is .
+ ///
+ /// The module's configuration is locked.
+ /// This property is being set to .
+ ///
+ public FileRequestHandlerCallback OnMappingFailed
+ {
+ get => _onMappingFailed;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _onMappingFailed = Validate.NotNull(nameof(value), value);
+ }
+ }
+
+ ///
+ /// Gets or sets a that is called whenever
+ /// the requested URL path has been mapped to a directory, but directory listing has been
+ /// disabled by setting to .
+ /// The default is .
+ ///
+ /// The module's configuration is locked.
+ /// This property is being set to .
+ ///
+ public FileRequestHandlerCallback OnDirectoryNotListable
+ {
+ get => _onDirectoryNotListable;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _onDirectoryNotListable = Validate.NotNull(nameof(value), value);
+ }
+ }
+
+ ///
+ /// Gets or sets a that is called whenever
+ /// the requested URL path has been mapped to a file or directory, but the request's
+ /// HTTP method is neither GET nor HEAD .
+ /// The default is .
+ ///
+ /// The module's configuration is locked.
+ /// This property is being set to .
+ ///
+ public FileRequestHandlerCallback OnMethodNotAllowed
+ {
+ get => _onMethodNotAllowed;
+ set
+ {
+ EnsureConfigurationNotLocked();
+ _onMethodNotAllowed = Validate.NotNull(nameof(value), value);
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ string IMimeTypeProvider.GetMimeType(string extension)
+ => _mimeTypeCustomizer.GetMimeType(extension);
+
+ bool IMimeTypeProvider.TryDetermineCompression(string mimeType, out bool preferCompression)
+ => _mimeTypeCustomizer.TryDetermineCompression(mimeType, out preferCompression);
+
+ ///
+ public void AddCustomMimeType(string extension, string mimeType)
+ => _mimeTypeCustomizer.AddCustomMimeType(extension, mimeType);
+
+ ///
+ public void PreferCompression(string mimeType, bool preferCompression)
+ => _mimeTypeCustomizer.PreferCompression(mimeType, preferCompression);
+
+ ///
+ /// Clears the part of used by this module.
+ ///
+ public void ClearCache()
+ {
+ _mappingCache?.Clear();
+ _cacheSection?.Clear();
+ }
+
+ ///
+ /// Releases unmanaged and - optionally - managed resources.
+ ///
+ /// to release both managed and unmanaged resources;
+ /// to release only unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing)
+ return;
+
+ if (_cacheSection != null)
+ Provider.ResourceChanged -= _cacheSection.Remove;
+
+ if (Provider is IDisposable disposableProvider)
+ disposableProvider.Dispose();
+
+ if (_cacheSection != null)
+ Cache.RemoveSection(_cacheSectionName);
+ }
+
+ ///
+ protected override void OnBeforeLockConfiguration()
+ {
+ base.OnBeforeLockConfiguration();
+
+ _mimeTypeCustomizer.Lock();
+ }
+
+ ///
+ protected override void OnStart(CancellationToken cancellationToken)
+ {
+ base.OnStart(cancellationToken);
+
+ _cacheSection = Cache.AddSection(_cacheSectionName);
+ Provider.ResourceChanged += _cacheSection.Remove;
+ Provider.Start(cancellationToken);
+ }
+
+ ///
+ protected override async Task OnRequestAsync(IHttpContext context)
+ {
+ MappedResourceInfo info;
+
+ var path = context.RequestedPath;
+
+ // Map the URL path to a mapped resource.
+ // DefaultDocument and DefaultExtension are handled here.
+ // Use the mapping cache if it exists.
+ if (_mappingCache == null)
+ {
+ info = MapUrlPath(path, context);
+ }
+ else if (!_mappingCache.TryGetValue(path, out info))
+ {
+ info = MapUrlPath(path, context);
+ if (info != null)
+ _mappingCache.AddOrUpdate(path, info, (_, __) => info);
+ }
+
+ if (info == null)
+ {
+ // If mapping failed, send a "404 Not Found" response, or whatever OnMappingFailed chooses to do.
+ // For example, it may return a default resource (think a folder of images and an imageNotFound.jpg),
+ // or redirect the request.
+ await OnMappingFailed(context, null).ConfigureAwait(false);
+ }
+ else if (!IsHttpMethodAllowed(context.Request, out var sendResponseBody))
+ {
+ // If there is a mapped resource, check that the HTTP method is either GET or HEAD.
+ // Otherwise, send a "405 Method Not Allowed" response, or whatever OnMethodNotAllowed chooses to do.
+ await OnMethodNotAllowed(context, info).ConfigureAwait(false);
+ }
+ else if (info.IsDirectory && DirectoryLister == null)
+ {
+ // If a directory listing was requested, but there is no DirectoryLister,
+ // send a "403 Unauthorized" response, or whatever OnDirectoryNotListable chooses to do.
+ // For example, one could prefer to send "404 Not Found" instead.
+ await OnDirectoryNotListable(context, info).ConfigureAwait(false);
+ }
+ else
+ {
+ await HandleResource(context, info, sendResponseBody).ConfigureAwait(false);
+ }
+ }
+
+ // Tells whether a request's HTTP method is suitable for processing by FileModule
+ // and, if so, whether a response body must be sent.
+ private static bool IsHttpMethodAllowed(IHttpRequest request, out bool sendResponseBody)
+ {
+ switch (request.HttpVerb)
+ {
+ case HttpVerbs.Head:
+ sendResponseBody = false;
+ return true;
+ case HttpVerbs.Get:
+ sendResponseBody = true;
+ return true;
+ default:
+ sendResponseBody = default;
+ return false;
+ }
+ }
+
+ // Prepares response headers for a "200 OK" or "304 Not Modified" response.
+ // RFC7232, Section 4.1
+ private static void PreparePositiveResponse(IHttpResponse response, MappedResourceInfo info, string contentType, string entityTag, Action setCompression)
+ {
+ setCompression(response);
+ response.ContentType = contentType;
+ response.Headers.Set(HttpHeaderNames.ETag, entityTag);
+ response.Headers.Set(HttpHeaderNames.LastModified, HttpDate.Format(info.LastModifiedUtc));
+ response.Headers.Set(HttpHeaderNames.CacheControl, "max-age=0, must-revalidate");
+ response.Headers.Set(HttpHeaderNames.AcceptRanges, "bytes");
+ }
+
+ // Attempts to map a module-relative URL path to a mapped resource,
+ // handling DefaultDocument and DefaultExtension.
+ // Returns null if not found.
+ // Directories mus be returned regardless of directory listing being enabled.
+ private MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
+ {
+ var result = Provider.MapUrlPath(urlPath, mimeTypeProvider);
+
+ // If urlPath maps to a file, no further searching is needed.
+ if (result?.IsFile ?? false)
+ return result;
+
+ // Look for a default document.
+ // Don't append an additional slash if the URL path is "/".
+ // The default document, if found, must be a file, not a directory.
+ if (DefaultDocument != null)
+ {
+ var defaultDocumentPath = urlPath + (urlPath.Length > 1 ? "/" : string.Empty) + DefaultDocument;
+ var defaultDocumentResult = Provider.MapUrlPath(defaultDocumentPath, mimeTypeProvider);
+ if (defaultDocumentResult?.IsFile ?? false)
+ return defaultDocumentResult;
+ }
+
+ // Try to apply default extension (but not if the URL path is "/",
+ // i.e. the only normalized, non-base URL path that ends in a slash).
+ // When the default extension is applied, the result must be a file.
+ if (DefaultExtension != null && urlPath.Length > 1)
+ {
+ var defaultExtensionResult = Provider.MapUrlPath(urlPath + DefaultExtension, mimeTypeProvider);
+ if (defaultExtensionResult?.IsFile ?? false)
+ return defaultExtensionResult;
+ }
+
+ return result;
+ }
+
+ private async Task HandleResource(IHttpContext context, MappedResourceInfo info, bool sendResponseBody)
+ {
+ // Try to extract resource information from cache.
+ var cachingThreshold = 1024L * Cache.MaxFileSizeKb;
+ if (!_cacheSection.TryGet(info.Path, out var cacheItem))
+ {
+ // Resource information not yet cached
+ cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
+ _cacheSection.Add(info.Path, cacheItem);
+ }
+ else if (!Provider.IsImmutable)
+ {
+ // Check whether the resource has changed.
+ // If so, discard the cache item and create a new one.
+ if (cacheItem.LastModifiedUtc != info.LastModifiedUtc || cacheItem.Length != info.Length)
+ {
+ _cacheSection.Remove(info.Path);
+ cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
+ _cacheSection.Add(info.Path, cacheItem);
+ }
+ }
+
+ /*
+ * Now we have a cacheItem for the resource.
+ * It may have been just created, or it may or may not have a cached content,
+ * depending upon the value of the ContentCaching property,
+ * the size of the resource, and the value of the
+ * MaxFileSizeKb of our Cache.
+ */
+
+ // If the content type is not a valid MIME type, assume the default.
+ var contentType = info.ContentType ?? DirectoryLister?.ContentType ?? MimeType.Default;
+ var mimeType = MimeType.StripParameters(contentType);
+ if (!MimeType.IsMimeType(mimeType, false))
+ contentType = mimeType = MimeType.Default;
+
+ // Next we're going to apply proactive negotiation
+ // to determine whether we agree with the client upon the compression
+ // (or lack of it) to use for the resource.
+ //
+ // The combination of partial responses and entity compression
+ // is not really standardized and could lead to a world of pain.
+ // Thus, if there is a Range header in the request, try to negotiate for no compression.
+ // Later, if there is compression anyway, we will ignore the Range header.
+ if (!context.TryDetermineCompression(mimeType, out var preferCompression))
+ preferCompression = true;
+ preferCompression &= context.Request.Headers.Get(HttpHeaderNames.Range) == null;
+ if (!context.Request.TryNegotiateContentEncoding(preferCompression, out var compressionMethod, out var setCompressionInResponse))
+ {
+ // If negotiation failed, the returned callback will do the right thing.
+ setCompressionInResponse(context.Response);
+ return;
+ }
+
+ var entityTag = info.GetEntityTag(compressionMethod);
+
+ // Send a "304 Not Modified" response if applicable.
+ //
+ // RFC7232, Section 3.3: "A recipient MUST ignore If-Modified-Since
+ // if the request contains an If-None-Match header field."
+ if (context.Request.CheckIfNoneMatch(entityTag, out var ifNoneMatchExists)
+ || (!ifNoneMatchExists && context.Request.CheckIfModifiedSince(info.LastModifiedUtc, out _)))
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.NotModified;
+ PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
+ return;
+ }
+
+ /*
+ * At this point we know the response is "200 OK",
+ * unless the request is a range request.
+ *
+ * RFC7233, Section 3.1: "The Range header field is evaluated after evaluating the precondition
+ * header fields defined in RFC7232, and only if the result in absence
+ * of the Range header field would be a 200 (OK) response. In other
+ * words, Range is ignored when a conditional GET would result in a 304
+ * (Not Modified) response."
+ */
+
+ // Before evaluating ranges, we must know the content length.
+ // This is easy for files, as it is stored in info.Length.
+ // Directories always have info.Length == 0; therefore,
+ // unless the directory listing is cached, we must generate it now
+ // (and cache it while we're there, if applicable).
+ var content = cacheItem.GetContent(compressionMethod);
+ if (info.IsDirectory && content == null)
+ {
+ long uncompressedLength;
+ (content, uncompressedLength) = await GenerateDirectoryListingAsync(context, info, compressionMethod)
+ .ConfigureAwait(false);
+ if (ContentCaching && uncompressedLength <= cachingThreshold)
+ cacheItem.SetContent(compressionMethod, content);
+ }
+
+ var contentLength = content?.Length ?? info.Length;
+
+ // Ignore range request is compression is enabled
+ // (or should I say forced, since negotiation has tried not to use it).
+ var partialStart = 0L;
+ var partialUpperBound = contentLength - 1;
+ var isPartial = compressionMethod == CompressionMethod.None
+ && context.Request.IsRangeRequest(contentLength, entityTag, info.LastModifiedUtc, out partialStart, out partialUpperBound);
+ var partialLength = contentLength;
+ if (isPartial)
+ {
+ // Prepare a "206 Partial Content" response.
+ partialLength = partialUpperBound - partialStart + 1;
+ context.Response.StatusCode = (int)HttpStatusCode.PartialContent;
+ PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
+ context.Response.Headers.Set(HttpHeaderNames.ContentRange, $"bytes {partialStart}-{partialUpperBound}/{contentLength}");
+ }
+ else
+ {
+ // Prepare a "200 OK" response.
+ PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
+ }
+
+ // If it's a HEAD request, we're done.
+ if (!sendResponseBody)
+ return;
+
+ // If content must be sent AND cached, first read it and store it.
+ // If the requested resource is a directory, we have already listed it by now,
+ // so it must be a file for content to be null.
+ if (content == null && ContentCaching && contentLength <= cachingThreshold)
+ {
+ using (var memoryStream = new MemoryStream())
+ {
+ using (var compressor = new CompressionStream(memoryStream, compressionMethod))
+ using (var source = Provider.OpenFile(info.Path))
+ {
+ await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ content = memoryStream.ToArray();
+ }
+
+ cacheItem.SetContent(compressionMethod, content);
+ }
+
+ // Transfer cached content if present.
+ if (content != null)
+ {
+ if (isPartial)
+ {
+ context.Response.ContentLength64 = partialLength;
+ await context.Response.OutputStream.WriteAsync(content, (int)partialStart, (int)partialLength, context.CancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ context.Response.ContentLength64 = content.Length;
+ await context.Response.OutputStream.WriteAsync(content, 0, content.Length, context.CancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ // Read and transfer content without caching.
+ using (var source = Provider.OpenFile(info.Path))
+ {
+ context.Response.SendChunked = true;
+
+ if (isPartial)
+ {
+ var buffer = new byte[WebServer.StreamCopyBufferSize];
+ if (source.CanSeek)
+ {
+ source.Position = partialStart;
+ }
+ else
+ {
+ var skipLength = (int)partialStart;
+ while (skipLength > 0)
+ {
+ var read = await source.ReadAsync(buffer, 0, Math.Min(skipLength, buffer.Length), context.CancellationToken)
+ .ConfigureAwait(false);
+
+ skipLength -= read;
+ }
+ }
+
+ var transferSize = partialLength;
+ while (transferSize >= WebServer.StreamCopyBufferSize)
+ {
+ var read = await source.ReadAsync(buffer, 0, WebServer.StreamCopyBufferSize, context.CancellationToken)
+ .ConfigureAwait(false);
+
+ await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
+ .ConfigureAwait(false);
+
+ transferSize -= read;
+ }
+
+ if (transferSize > 0)
+ {
+ var read = await source.ReadAsync(buffer, 0, (int)transferSize, context.CancellationToken)
+ .ConfigureAwait(false);
+
+ await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ using (var compressor = new CompressionStream(context.Response.OutputStream, compressionMethod))
+ {
+ await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+ }
+
+ // Uses DirectoryLister to generate a directory listing asynchronously.
+ // Returns a tuple of the generated content and its *uncompressed* length
+ // (useful to decide whether it can be cached).
+ private async Task<(byte[], long)> GenerateDirectoryListingAsync(
+ IHttpContext context,
+ MappedResourceInfo info,
+ CompressionMethod compressionMethod)
+ {
+ using (var memoryStream = new MemoryStream())
+ {
+ long uncompressedLength;
+ using (var stream = new CompressionStream(memoryStream, compressionMethod))
+ {
+ await DirectoryLister.ListDirectoryAsync(
+ info,
+ context.Request.Url.AbsolutePath,
+ Provider.GetDirectoryEntries(info.Path, context),
+ stream,
+ context.CancellationToken).ConfigureAwait(false);
+
+ uncompressedLength = stream.UncompressedLength;
+ }
+
+ return (memoryStream.ToArray(), uncompressedLength);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileModuleExtensions.cs b/src/EmbedIO/Files/FileModuleExtensions.cs
new file mode 100644
index 000000000..4009ca6f0
--- /dev/null
+++ b/src/EmbedIO/Files/FileModuleExtensions.cs
@@ -0,0 +1,261 @@
+using System;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides extension methods for and derived classes.
+ ///
+ public static class FileModuleExtensions
+ {
+ ///
+ /// Sets the used by a module to store hashes and,
+ /// optionally, file contents and rendered directory listings.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// An instance of .
+ /// with its Cache property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ /// is .
+ ///
+ public static TModule WithCache(this TModule @this, FileCache value)
+ where TModule : FileModule
+ {
+ @this.Cache = value;
+ return @this;
+ }
+
+ ///
+ /// Sets a value indicating whether a module caches the contents of files
+ /// and directory listings.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// to enable caching of contents;
+ /// to disable it.
+ /// with its ContentCaching property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithContentCaching(this TModule @this, bool value)
+ where TModule : FileModule
+ {
+ @this.ContentCaching = value;
+ return @this;
+ }
+
+ ///
+ /// Enables caching of file contents and directory listings on a module.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// with its ContentCaching property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithContentCaching(this TModule @this)
+ where TModule : FileModule
+ {
+ @this.ContentCaching = true;
+ return @this;
+ }
+
+ ///
+ /// Disables caching of file contents and directory listings on a module.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// with its ContentCaching property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithoutContentCaching(this TModule @this)
+ where TModule : FileModule
+ {
+ @this.ContentCaching = false;
+ return @this;
+ }
+
+ ///
+ /// Sets the name of the default document served, if it exists, instead of a directory listing
+ /// when the path of a requested URL maps to a directory.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// The name of the default document.
+ /// with its DefaultDocument property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithDefaultDocument(this TModule @this, string value)
+ where TModule : FileModule
+ {
+ @this.DefaultDocument = value;
+ return @this;
+ }
+
+ ///
+ /// Sets the name of the default document to .
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// with its DefaultDocument property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithoutDefaultDocument(this TModule @this)
+ where TModule : FileModule
+ {
+ @this.DefaultDocument = null;
+ return @this;
+ }
+
+ ///
+ /// Sets the default extension appended to requested URL paths that do not map
+ /// to any file or directory.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// The default extension.
+ /// with its DefaultExtension property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ /// is a non- ,
+ /// non-empty string that does not start with a period (. ).
+ ///
+ public static TModule WithDefaultExtension(this TModule @this, string value)
+ where TModule : FileModule
+ {
+ @this.DefaultExtension = value;
+ return @this;
+ }
+
+ ///
+ /// Sets the default extension to .
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// with its DefaultExtension property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithoutDefaultExtension(this TModule @this)
+ where TModule : FileModule
+ {
+ @this.DefaultExtension = null;
+ return @this;
+ }
+
+ ///
+ /// Sets the interface used to generate
+ /// directory listing in a module.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// An interface, or
+ /// to disable the generation of directory listings.
+ /// with its DirectoryLister property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithDirectoryLister(this TModule @this, IDirectoryLister value)
+ where TModule : FileModule
+ {
+ @this.DirectoryLister = value;
+ return @this;
+ }
+
+ ///
+ /// Sets a module's DirectoryLister property
+ /// to , disabling the generation of directory listings.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// with its DirectoryLister property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ ///
+ public static TModule WithoutDirectoryLister(this TModule @this)
+ where TModule : FileModule
+ {
+ @this.DirectoryLister = null;
+ return @this;
+ }
+
+ ///
+ /// Sets a that is called by a module whenever
+ /// the requested URL path could not be mapped to any file or directory.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// The method to call.
+ /// with its OnMappingFailed property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ /// is .
+ ///
+ ///
+ public static TModule HandleMappingFailed(this TModule @this, FileRequestHandlerCallback callback)
+ where TModule : FileModule
+ {
+ @this.OnMappingFailed = callback;
+ return @this;
+ }
+
+ ///
+ /// Sets a that is called by a module whenever
+ /// the requested URL path has been mapped to a directory, but directory listing has been
+ /// disabled.
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// The method to call.
+ /// with its OnDirectoryNotListable property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ /// is .
+ ///
+ ///
+ public static TModule HandleDirectoryNotListable(this TModule @this, FileRequestHandlerCallback callback)
+ where TModule : FileModule
+ {
+ @this.OnDirectoryNotListable = callback;
+ return @this;
+ }
+
+ ///
+ /// Sets a that is called by a module whenever
+ /// the requested URL path has been mapped to a file or directory, but the request's
+ /// HTTP method is neither GET nor HEAD .
+ ///
+ /// The type of the module on which this method is called.
+ /// The module on which this method is called.
+ /// The method to call.
+ /// with its OnMethodNotAllowed property
+ /// set to .
+ /// is .
+ /// The configuration of is locked.
+ /// is .
+ ///
+ ///
+ public static TModule HandleMethodNotAllowed(this TModule @this, FileRequestHandlerCallback callback)
+ where TModule : FileModule
+ {
+ @this.OnMethodNotAllowed = callback;
+ return @this;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileRequestHandler.cs b/src/EmbedIO/Files/FileRequestHandler.cs
new file mode 100644
index 000000000..2853fba22
--- /dev/null
+++ b/src/EmbedIO/Files/FileRequestHandler.cs
@@ -0,0 +1,53 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides standard handler callbacks for .
+ ///
+ ///
+ public static class FileRequestHandler
+ {
+#pragma warning disable CA1801 // Unused parameters - Must respect FileRequestHandlerCallback signature.
+ ///
+ /// Unconditionally passes a request down the module chain.
+ ///
+ /// A interface representing the context of the request.
+ /// If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
+ /// otherwise, .
+ /// This method never returns; it throws an exception instead.
+ public static Task PassThrough(IHttpContext context, MappedResourceInfo info)
+ => throw RequestHandler.PassThrough();
+
+ ///
+ /// Unconditionally sends a 403 Unauthorized response.
+ ///
+ /// A interface representing the context of the request.
+ /// If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
+ /// otherwise, .
+ /// This method never returns; it throws a instead.
+ public static Task ThrowUnauthorized(IHttpContext context, MappedResourceInfo info)
+ => throw HttpException.Unauthorized();
+
+ ///
+ /// Unconditionally sends a 404 Not Found response.
+ ///
+ /// A interface representing the context of the request.
+ /// If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
+ /// otherwise, .
+ /// This method never returns; it throws a instead.
+ public static Task ThrowNotFound(IHttpContext context, MappedResourceInfo info)
+ => throw HttpException.NotFound();
+
+ ///
+ /// Unconditionally sends a 405 Method Not Allowed response.
+ ///
+ /// A interface representing the context of the request.
+ /// If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
+ /// otherwise, .
+ /// This method never returns; it throws a instead.
+ public static Task ThrowMethodNotAllowed(IHttpContext context, MappedResourceInfo info)
+ => throw HttpException.MethodNotAllowed();
+#pragma warning restore CA1801
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileRequestHandlerCallback.cs b/src/EmbedIO/Files/FileRequestHandlerCallback.cs
new file mode 100644
index 000000000..c6a5d3665
--- /dev/null
+++ b/src/EmbedIO/Files/FileRequestHandlerCallback.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// A callback used to handle a request in .
+ ///
+ /// A interface representing the context of the request.
+ /// If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
+ /// otherwise, .
+ /// A representing the ongoing operation.
+ public delegate Task FileRequestHandlerCallback(IHttpContext context, MappedResourceInfo info);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/FileSystemProvider.cs b/src/EmbedIO/Files/FileSystemProvider.cs
new file mode 100644
index 000000000..b746b4d25
--- /dev/null
+++ b/src/EmbedIO/Files/FileSystemProvider.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides access to the local file system to a .
+ ///
+ ///
+ public class FileSystemProvider : IDisposable, IFileProvider
+ {
+ private readonly FileSystemWatcher _watcher;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system path.
+ /// if files and directories in
+ /// are not expected to change during a web server's
+ /// lifetime; otherwise.
+ /// is .
+ /// is not a valid local path.
+ ///
+ public FileSystemProvider(string fileSystemPath, bool isImmutable)
+ {
+ FileSystemPath = Validate.LocalPath(nameof(fileSystemPath), fileSystemPath, true);
+ IsImmutable = isImmutable;
+
+ if (!IsImmutable)
+ _watcher = new FileSystemWatcher(FileSystemPath);
+ }
+
+ ///
+ /// Finalizes an instance of the class.
+ ///
+ ~FileSystemProvider()
+ {
+ Dispose(false);
+ }
+
+ ///
+ public event Action ResourceChanged;
+
+ ///
+ /// Gets the file system path from which files are retrieved.
+ ///
+ public string FileSystemPath { get; }
+
+ ///
+ public bool IsImmutable { get; }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public void Start(CancellationToken cancellationToken)
+ {
+ if (_watcher != null)
+ {
+ _watcher.Changed += Watcher_ChangedOrDeleted;
+ _watcher.Deleted += Watcher_ChangedOrDeleted;
+ _watcher.Renamed += Watcher_Renamed;
+ _watcher.EnableRaisingEvents = true;
+ }
+ }
+
+ ///
+ public MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
+ {
+ urlPath = urlPath.Substring(1); // Drop the initial slash
+ string localPath;
+
+ // Disable CA1031 as there's little we can do if IsPathRooted or GetFullPath fails.
+#pragma warning disable CA1031
+ try
+ {
+ // Bail out early if the path is a rooted path,
+ // as Path.Combine would ignore our base path.
+ // See https://docs.microsoft.com/en-us/dotnet/api/system.io.path.combine
+ // (particularly the Remarks section).
+ //
+ // Under Windows, a relative URL path may be a full filesystem path
+ // (e.g. "D:\foo\bar" or "\\192.168.0.1\Shared\MyDocuments\BankAccounts.docx").
+ // Under Unix-like operating systems we have no such problems, as relativeUrlPath
+ // can never start with a slash; however, loading one more class from Swan
+ // just to check the OS type would probably outweigh calling IsPathRooted.
+ if (Path.IsPathRooted(urlPath))
+ return null;
+
+ // Convert the relative URL path to a relative filesystem path
+ // (practically a no-op under Unix-like operating systems)
+ // and combine it with our base local path to obtain a full path.
+ localPath = Path.Combine(FileSystemPath, urlPath.Replace('/', Path.DirectorySeparatorChar));
+
+ // Use GetFullPath as an additional safety check
+ // for relative paths that contain a rooted path
+ // (e.g. "valid/path/C:\Windows\System.ini")
+ localPath = Path.GetFullPath(localPath);
+ }
+ catch
+ {
+ // Both IsPathRooted and GetFullPath throw exceptions
+ // if a path contains invalid characters or is otherwise invalid;
+ // bail out in this case too, as the path would not exist on disk anyway.
+ return null;
+ }
+#pragma warning restore CA1031
+
+ // As a final precaution, check that the resulting local path
+ // is inside the folder intended to be served.
+ if (!localPath.StartsWith(FileSystemPath, StringComparison.Ordinal))
+ return null;
+
+ if (File.Exists(localPath))
+ return GetMappedFileInfo(mimeTypeProvider, localPath);
+
+ if (Directory.Exists(localPath))
+ return GetMappedDirectoryInfo(localPath);
+
+ return null;
+ }
+
+ ///
+ public Stream OpenFile(string path) => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+
+ ///
+ public IEnumerable GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
+ => new DirectoryInfo(path).EnumerateFileSystemInfos()
+ .Select(fsi => GetMappedResourceInfo(mimeTypeProvider, fsi));
+
+ ///
+ /// Releases unmanaged and - optionally - managed resources.
+ ///
+ /// to release both managed and unmanaged resources;
+ /// to release only unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ ResourceChanged = null; // Release references to listeners
+
+ if (_watcher != null)
+ {
+ _watcher.EnableRaisingEvents = false;
+ _watcher.Changed -= Watcher_ChangedOrDeleted;
+ _watcher.Deleted -= Watcher_ChangedOrDeleted;
+ _watcher.Renamed -= Watcher_Renamed;
+
+ if (disposing)
+ _watcher.Dispose();
+ }
+ }
+
+ private static MappedResourceInfo GetMappedFileInfo(IMimeTypeProvider mimeTypeProvider, string localPath)
+ => GetMappedFileInfo(mimeTypeProvider, new FileInfo(localPath));
+
+ private static MappedResourceInfo GetMappedFileInfo(IMimeTypeProvider mimeTypeProvider, FileInfo info)
+ => MappedResourceInfo.ForFile(
+ info.FullName,
+ info.Name,
+ info.LastWriteTimeUtc,
+ info.Length,
+ mimeTypeProvider.GetMimeType(info.Extension));
+
+ private static MappedResourceInfo GetMappedDirectoryInfo(string localPath)
+ => GetMappedDirectoryInfo(new DirectoryInfo(localPath));
+
+ private static MappedResourceInfo GetMappedDirectoryInfo(DirectoryInfo info)
+ => MappedResourceInfo.ForDirectory(info.FullName, info.Name, info.LastWriteTimeUtc);
+
+ private static MappedResourceInfo GetMappedResourceInfo(IMimeTypeProvider mimeTypeProvider, FileSystemInfo info)
+ => info is DirectoryInfo directoryInfo
+ ? GetMappedDirectoryInfo(directoryInfo)
+ : GetMappedFileInfo(mimeTypeProvider, (FileInfo)info);
+
+ private void Watcher_ChangedOrDeleted(object sender, FileSystemEventArgs e)
+ => ResourceChanged?.Invoke(e.FullPath);
+
+ private void Watcher_Renamed(object sender, RenamedEventArgs e)
+ => ResourceChanged?.Invoke(e.OldFullPath);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/IDirectoryLister.cs b/src/EmbedIO/Files/IDirectoryLister.cs
new file mode 100644
index 000000000..0a2ba9a7e
--- /dev/null
+++ b/src/EmbedIO/Files/IDirectoryLister.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Represents an object that can render a directory listing to a stream.
+ ///
+ public interface IDirectoryLister
+ {
+ ///
+ /// Gets the MIME type of generated directory listings.
+ ///
+ string ContentType { get; }
+
+ ///
+ /// Asynchronously generate a directory listing.
+ ///
+ /// A containing information about
+ /// the directory which is to be listed.
+ /// The absolute URL path that was mapped to .
+ /// An enumeration of the entries in the directory represented by .
+ /// A to which the directory listing must be written.
+ /// A used to cancel the operation.
+ /// A representing the ongoing operation.
+ Task ListDirectoryAsync(
+ MappedResourceInfo info,
+ string absoluteUrlPath,
+ IEnumerable entries,
+ Stream stream,
+ CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/IFileProvider.cs b/src/EmbedIO/Files/IFileProvider.cs
new file mode 100644
index 000000000..9ed5abdf8
--- /dev/null
+++ b/src/EmbedIO/Files/IFileProvider.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Represents an object that can provide files and/or directories to be served by a .
+ ///
+ public interface IFileProvider
+ {
+ ///
+ /// Occurs when a file or directory provided by this instance is modified or removed.
+ /// The event's parameter is the provider-specific path of the resource that changed.
+ ///
+ event Action ResourceChanged;
+
+ ///
+ /// Gets a value indicating whether the files and directories provided by this instance
+ /// will never change.
+ ///
+ bool IsImmutable { get; }
+
+ ///
+ /// Signals a file provider that the web server is starting.
+ ///
+ /// A used to stop the web server.
+ void Start(CancellationToken cancellationToken);
+
+ ///
+ /// Maps a URL path to a provider-specific path.
+ ///
+ /// The URL path.
+ /// An interface to use
+ /// for determining the MIME type of a file.
+ /// A provider-specific path identifying a file or directory,
+ /// or if this instance cannot provide a resource associated
+ /// to .
+ MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider);
+
+ ///
+ /// Opens a file for reading.
+ ///
+ /// The provider-specific path for the file.
+ ///
+ /// A readable of the file's contents.
+ ///
+ Stream OpenFile(string path);
+
+ ///
+ /// Returns an enumeration of the entries of a directory.
+ ///
+ /// The provider-specific path for the directory.
+ /// An interface to use
+ /// for determining the MIME type of files.
+ /// An enumeration of objects identifying the entries
+ /// in the directory identified by .
+ IEnumerable GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/Internal/Base64Utility.cs b/src/EmbedIO/Files/Internal/Base64Utility.cs
new file mode 100644
index 000000000..19d157982
--- /dev/null
+++ b/src/EmbedIO/Files/Internal/Base64Utility.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace EmbedIO.Files.Internal
+{
+ internal static class Base64Utility
+ {
+ // long is 8 bytes
+ // base64 of 8 bytes is 12 chars, but the last one is padding
+ public static string LongToBase64(long value)
+ => Convert.ToBase64String(BitConverter.GetBytes(value)).Substring(0, 11);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/Internal/EntityTag.cs b/src/EmbedIO/Files/Internal/EntityTag.cs
new file mode 100644
index 000000000..288d5942e
--- /dev/null
+++ b/src/EmbedIO/Files/Internal/EntityTag.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text;
+
+namespace EmbedIO.Files.Internal
+{
+ internal static class EntityTag
+ {
+ public static string Compute(DateTime lastModifiedUtc, long length, CompressionMethod compressionMethod)
+ {
+ var sb = new StringBuilder()
+ .Append('"')
+ .Append(Base64Utility.LongToBase64(lastModifiedUtc.Ticks))
+ .Append(Base64Utility.LongToBase64(length));
+
+ switch (compressionMethod)
+ {
+ case CompressionMethod.Deflate:
+ sb.Append('-').Append(CompressionMethodNames.Deflate);
+ break;
+ case CompressionMethod.Gzip:
+ sb.Append('-').Append(CompressionMethodNames.Gzip);
+ break;
+ }
+
+ return sb.Append('"').ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/Internal/FileCacheItem.cs b/src/EmbedIO/Files/Internal/FileCacheItem.cs
new file mode 100644
index 000000000..e416c4dd6
--- /dev/null
+++ b/src/EmbedIO/Files/Internal/FileCacheItem.cs
@@ -0,0 +1,167 @@
+using System;
+using EmbedIO.Internal;
+
+namespace EmbedIO.Files.Internal
+{
+ internal sealed class FileCacheItem
+ {
+#pragma warning disable SA1401 // Field should be private - performance is a strongest concern here.
+ // These fields create a sort of linked list of items
+ // inside the cache's dictionary.
+ // Their purpose is to keep track of items
+ // in order from least to most recently used.
+ internal string PreviousKey;
+ internal string NextKey;
+ internal long LastUsedAt;
+#pragma warning restore SA1401
+
+ // Size of a pointer in bytes
+ private static readonly long SizeOfPointer = Environment.Is64BitProcess ? 8 : 4;
+
+ // Size of a WeakReference in bytes
+ private static readonly long SizeOfWeakReference = Environment.Is64BitProcess ? 16 : 32;
+
+ // Educated guess about the size of an Item in memory (see comments on constructor).
+ // 3 * SizeOfPointer + total size of fields, rounded up to a multiple of 16.
+ //
+ // Computed as follows:
+ //
+ // * for 32-bit:
+ // - initialize count to 3 (number of "hidden" pointers that compose the object header)
+ // - for every field / auto property, in order of declaration:
+ // - increment count by 1 for reference types, 2 for long and DateTime
+ // (as of time of writing there are no fields of other types here)
+ // - increment again by 1 if this field "weighs" 1 and the next one "weighs" 2
+ // (padding for field alignment)
+ // - multiply count by 4 (size of a pointer)
+ // - if the result is not a multiple of 16, round it up to next multiple of 16
+ //
+ // * for 64-bit:
+ // - initialize count to 3 (number of "hidden" pointers that compose the object header)
+ // - for every field / auto property, in order of declaration, increment count by 1
+ // (at the time of writing there are no fields here that need padding on 64-bit)
+ // - multiply count by 8 (size of a pointer)
+ // - if the result is not a multiple of 16, round it up to next multiple of 16
+ private static readonly long SizeOfItem = Environment.Is64BitProcess ? 96 : 128;
+
+ // Used to update total size of section.
+ // Weak reference avoids circularity.
+ private readonly WeakReference _section;
+
+ // There are only 3 possible compression methods,
+ // hence a dictionary (or two dictionaries) would be overkill.
+ private byte[] _uncompressedContent;
+ private byte[] _gzippedContent;
+ private byte[] _deflatedContent;
+
+ internal FileCacheItem(FileCache.Section section, DateTime lastModifiedUtc, long length)
+ {
+ _section = new WeakReference(section);
+
+ LastModifiedUtc = lastModifiedUtc;
+ Length = length;
+
+ // There is no way to know the actual size of an object at runtime.
+ // This method makes some educated guesses, based on the following
+ // article (among others):
+ // https://codingsight.com/precise-computation-of-clr-object-size/
+ // PreviousKey and NextKey values aren't counted in
+ // because they are just references to existing strings.
+ SizeInCache = SizeOfItem + SizeOfWeakReference;
+ }
+
+ public DateTime LastModifiedUtc { get; }
+
+ public long Length { get; }
+
+ // This is the (approximate) in-memory size of this object.
+ // It is NOT the length of the cache resource!
+ public long SizeInCache { get; private set; }
+
+ public byte[] GetContent(CompressionMethod compressionMethod)
+ {
+ // If there are both entity tag and content, use them.
+ switch (compressionMethod)
+ {
+ case CompressionMethod.Deflate:
+ if (_deflatedContent != null) return _deflatedContent;
+ break;
+ case CompressionMethod.Gzip:
+ if (_gzippedContent != null) return _gzippedContent;
+ break;
+ default:
+ if (_uncompressedContent != null) return _uncompressedContent;
+ break;
+ }
+
+ // Try to convert existing content, if any.
+ byte[] content;
+ if (_uncompressedContent != null)
+ {
+ content = CompressionUtility.ConvertCompression(_uncompressedContent, CompressionMethod.None, compressionMethod);
+ }
+ else if (_gzippedContent != null)
+ {
+ content = CompressionUtility.ConvertCompression(_gzippedContent, CompressionMethod.Gzip, compressionMethod);
+ }
+ else if (_deflatedContent != null)
+ {
+ content = CompressionUtility.ConvertCompression(_deflatedContent, CompressionMethod.Deflate, compressionMethod);
+ }
+ else
+ {
+ // No content whatsoever.
+ return null;
+ }
+
+ return SetContent(compressionMethod, content);
+ }
+
+ public byte[] SetContent(CompressionMethod compressionMethod, byte[] content)
+ {
+ // This is the bare minimum locking we need
+ // to ensure we don't mess sizes up.
+ byte[] oldContent;
+ lock (this)
+ {
+ switch (compressionMethod)
+ {
+ case CompressionMethod.Deflate:
+ oldContent = _deflatedContent;
+ _deflatedContent = content;
+ break;
+ case CompressionMethod.Gzip:
+ oldContent = _gzippedContent;
+ _gzippedContent = content;
+ break;
+ default:
+ oldContent = _uncompressedContent;
+ _uncompressedContent = content;
+ break;
+ }
+ }
+
+ var sizeDelta = GetSizeOf(content) - GetSizeOf(oldContent);
+ SizeInCache += sizeDelta;
+ if (_section.TryGetTarget(out var section))
+ section.UpdateTotalSize(sizeDelta);
+
+ return content;
+ }
+
+ // Round up to a multiple of 16
+ private static long RoundUpTo16(long n)
+ {
+ var remainder = n % 16;
+ return remainder > 0 ? n + (16 - remainder) : n;
+ }
+
+ // The size of a string is 3 * SizeOfPointer + 4 (size of Length field) + 2 (size of char) * Length
+ // String has a m_firstChar field that always exists at the same address as its array of characters,
+ // thus even the empty string is considered of length 1.
+ private static long GetSizeOf(string str) => str == null ? 0 : RoundUpTo16(3 * SizeOfPointer) + 4 + (2 * Math.Min(1, str.Length));
+
+ // The size of a byte array is 3 * SizeOfPointer + 1 (size of byte) * Length
+ private static long GetSizeOf(byte[] arr) => arr == null ? 0 : RoundUpTo16(3 * SizeOfPointer) + arr.Length;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/Internal/HtmlDirectoryLister.cs b/src/EmbedIO/Files/Internal/HtmlDirectoryLister.cs
new file mode 100644
index 000000000..6d0262e1f
--- /dev/null
+++ b/src/EmbedIO/Files/Internal/HtmlDirectoryLister.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Files.Internal
+{
+ internal class HtmlDirectoryLister : IDirectoryLister
+ {
+ private static readonly Lazy LazyInstance = new Lazy(() => new HtmlDirectoryLister());
+
+ private HtmlDirectoryLister()
+ {
+ }
+
+ public static IDirectoryLister Instance => LazyInstance.Value;
+
+ public string ContentType { get; } = MimeType.Html + "; encoding=" + Encoding.UTF8.WebName;
+
+ public async Task ListDirectoryAsync(
+ MappedResourceInfo info,
+ string absoluteUrlPath,
+ IEnumerable entries,
+ Stream stream,
+ CancellationToken cancellationToken)
+ {
+ const int MaxEntryLength = 50;
+ const int SizeIndent = -20; // Negative for right alignment
+
+ SelfCheck.Assert(info.IsDirectory, $"{nameof(HtmlDirectoryLister)}.{nameof(ListDirectoryAsync)} invoked with a file, not a directory.");
+
+ var encodedPath = WebUtility.HtmlEncode(absoluteUrlPath);
+ using (var text = new StreamWriter(stream, Encoding.UTF8))
+ {
+ text.Write("Index of ");
+ text.Write(encodedPath);
+ text.Write(" Index of ");
+ text.Write(encodedPath);
+ text.Write(" ");
+
+ if (encodedPath.Length > 1)
+ text.Write("../ \n");
+
+ entries = entries.ToArray();
+
+ foreach (var directory in entries.Where(m => m.IsDirectory).OrderBy(e => e.Name))
+ {
+ text.Write($"{WebUtility.HtmlEncode(directory.Name)} ");
+ text.Write(new string(' ', Math.Max(1, MaxEntryLength - directory.Name.Length + 1)));
+ text.Write(HttpDate.Format(directory.LastModifiedUtc));
+ text.Write('\n');
+ await Task.Yield();
+ }
+
+ foreach (var file in entries.Where(m => m.IsFile).OrderBy(e => e.Name))
+ {
+ text.Write($"{WebUtility.HtmlEncode(file.Name)} ");
+ text.Write(new string(' ', Math.Max(1, MaxEntryLength - file.Name.Length + 1)));
+ text.Write(HttpDate.Format(file.LastModifiedUtc));
+ text.Write($" {file.Length.ToString("#,###", CultureInfo.InvariantCulture),SizeIndent}\n");
+ await Task.Yield();
+ }
+
+ text.Write(" ");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/Internal/MappedResourceInfoExtensions.cs b/src/EmbedIO/Files/Internal/MappedResourceInfoExtensions.cs
new file mode 100644
index 000000000..4d780d452
--- /dev/null
+++ b/src/EmbedIO/Files/Internal/MappedResourceInfoExtensions.cs
@@ -0,0 +1,8 @@
+namespace EmbedIO.Files.Internal
+{
+ internal static class MappedResourceInfoExtensions
+ {
+ public static string GetEntityTag(this MappedResourceInfo @this, CompressionMethod compressionMethod)
+ => EntityTag.Compute(@this.LastModifiedUtc, @this.Length, compressionMethod);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/MappedResourceInfo.cs b/src/EmbedIO/Files/MappedResourceInfo.cs
new file mode 100644
index 000000000..6ce57c3ee
--- /dev/null
+++ b/src/EmbedIO/Files/MappedResourceInfo.cs
@@ -0,0 +1,80 @@
+using System;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Contains information about a resource served via a .
+ ///
+ public sealed class MappedResourceInfo
+ {
+ private MappedResourceInfo(string path, string name, DateTime lastModifiedUtc, long length, string contentType)
+ {
+ Path = path;
+ Name = name;
+ LastModifiedUtc = lastModifiedUtc;
+ Length = length;
+ ContentType = contentType;
+ }
+
+ ///
+ /// Gets a value indicating whether this instance represents a directory.
+ ///
+ public bool IsDirectory => ContentType == null;
+
+ ///
+ /// Gets a value indicating whether this instance represents a file.
+ ///
+ public bool IsFile => ContentType != null;
+
+ ///
+ /// Gets a unique, provider-specific path for the resource.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the name of the resource, as it would appear in a directory listing.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the UTC date and time of the last modification made to the resource.
+ ///
+ public DateTime LastModifiedUtc { get; }
+
+ ///
+ /// If is , gets the length of the file, expressed in bytes.
+ /// If is , this property is always zero.
+ ///
+ public long Length { get; }
+
+ ///
+ /// If is , gets a MIME type describing the kind of contents of the file.
+ /// If is , this property is always .
+ ///
+ public string ContentType { get; }
+
+ ///
+ /// Creates and returns a new instance of the class,
+ /// representing a file.
+ ///
+ /// A unique, provider-specific path for the file.
+ /// The name of the file, as it would appear in a directory listing.
+ /// The UTC date and time of the last modification made to the file.
+ /// The length of the file, expressed in bytes.
+ /// A MIME type describing the kind of contents of the file.
+ /// A newly-constructed instance of .
+ public static MappedResourceInfo ForFile(string path, string name, DateTime lastModifiedUtc, long size, string contentType)
+ => new MappedResourceInfo(path, name, lastModifiedUtc, size, contentType ?? MimeType.Default);
+
+ ///
+ /// Creates and returns a new instance of the class,
+ /// representing a directory.
+ ///
+ /// A unique, provider-specific path for the directory.
+ /// The name of the directory, as it would appear in a directory listing.
+ /// The UTC date and time of the last modification made to the directory.
+ /// A newly-constructed instance of .
+ public static MappedResourceInfo ForDirectory(string path, string name, DateTime lastModifiedUtc)
+ => new MappedResourceInfo(path, name, lastModifiedUtc, 0, null);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/ResourceFileProvider.cs b/src/EmbedIO/Files/ResourceFileProvider.cs
new file mode 100644
index 000000000..8a4aab31b
--- /dev/null
+++ b/src/EmbedIO/Files/ResourceFileProvider.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides access to embedded resources to a .
+ ///
+ ///
+ public class ResourceFileProvider : IFileProvider
+ {
+ private readonly DateTime _fileTime = DateTime.UtcNow;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The assembly where served files are contained as embedded resources.
+ /// A string to prepend to provider-specific paths
+ /// to form the name of a manifest resource in .
+ /// is .
+ public ResourceFileProvider(Assembly assembly, string pathPrefix)
+ {
+ Assembly = Validate.NotNull(nameof(assembly), assembly);
+ PathPrefix = pathPrefix ?? string.Empty;
+ }
+
+ ///
+ public event Action ResourceChanged
+ {
+ add { }
+ remove { }
+ }
+
+ ///
+ /// Gets the assembly where served files are contained as embedded resources.
+ ///
+ public Assembly Assembly { get; }
+
+ ///
+ /// Gets a string that is prepended to provider-specific paths to form the name of a manifest resource in .
+ ///
+ public string PathPrefix { get; }
+
+ ///
+ public bool IsImmutable => true;
+
+ ///
+ public void Start(CancellationToken cancellationToken)
+ {
+ }
+
+ ///
+ public MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
+ {
+ var resourceName = PathPrefix + urlPath.Replace('/', '.');
+
+ long size;
+ try
+ {
+ using (var stream = Assembly.GetManifestResourceStream(resourceName))
+ {
+ if (stream == null || stream == Stream.Null)
+ return null;
+
+ size = stream.Length;
+ }
+ }
+ catch (FileNotFoundException)
+ {
+ return null;
+ }
+
+ var lastSlashPos = urlPath.LastIndexOf('/');
+ var name = urlPath.Substring(lastSlashPos + 1);
+
+ return MappedResourceInfo.ForFile(
+ resourceName,
+ name,
+ _fileTime,
+ size,
+ mimeTypeProvider.GetMimeType(Path.GetExtension(name)));
+ }
+
+ ///
+ public Stream OpenFile(string path) => Assembly.GetManifestResourceStream(path);
+
+ ///
+ public IEnumerable GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
+ => Enumerable.Empty();
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Files/ZipFileProvider.cs b/src/EmbedIO/Files/ZipFileProvider.cs
new file mode 100644
index 000000000..759c9331d
--- /dev/null
+++ b/src/EmbedIO/Files/ZipFileProvider.cs
@@ -0,0 +1,108 @@
+using EmbedIO.Utilities;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Threading;
+
+namespace EmbedIO.Files
+{
+ ///
+ /// Provides access to files contained in a .zip file to a .
+ ///
+ ///
+ public class ZipFileProvider : IDisposable, IFileProvider
+ {
+ private readonly ZipArchive _zipArchive;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The zip file path.
+ public ZipFileProvider(string zipFilePath)
+ : this(new FileStream(Validate.LocalPath(nameof(zipFilePath), zipFilePath, true), FileMode.Open))
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stream that contains the archive.
+ /// to leave the stream open after the web server
+ /// is disposed; otherwise, .
+ public ZipFileProvider(Stream stream, bool leaveOpen = false)
+ {
+ _zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen);
+ }
+
+ ///
+ /// Finalizes an instance of the class.
+ ///
+ ~ZipFileProvider()
+ {
+ Dispose(false);
+ }
+
+ ///
+ public event Action ResourceChanged
+ {
+ add { }
+ remove { }
+ }
+
+ ///
+ public bool IsImmutable => true;
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public void Start(CancellationToken cancellationToken)
+ {
+ }
+
+ ///
+ public MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
+ {
+ if (urlPath.Length == 1)
+ return null;
+
+ var entry = _zipArchive.GetEntry(urlPath.Substring(1));
+ if (entry == null)
+ return null;
+
+ return MappedResourceInfo.ForFile(
+ entry.FullName,
+ entry.Name,
+ entry.LastWriteTime.DateTime,
+ entry.Length,
+ mimeTypeProvider.GetMimeType(Path.GetExtension(entry.Name)));
+ }
+
+ ///
+ public Stream OpenFile(string path)
+ => _zipArchive.GetEntry(path)?.Open();
+
+ ///
+ public IEnumerable GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
+ => Enumerable.Empty();
+
+ ///
+ /// Releases unmanaged and - optionally - managed resources.
+ ///
+ /// to release both managed and unmanaged resources;
+ /// to release only unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing)
+ return;
+
+ _zipArchive.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions-Items.cs b/src/EmbedIO/HttpContextExtensions-Items.cs
new file mode 100644
index 000000000..ffa631b48
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions-Items.cs
@@ -0,0 +1,45 @@
+using System;
+
+namespace EmbedIO
+{
+ partial class HttpContextExtensions
+ {
+ /// Gets the item associated with the specified key.
+ /// The desired type of the item.
+ /// The on which this method is called.
+ /// The key whose value to get from the Items dictionary.
+ ///
+ /// When this method returns, the item associated with the specified key,
+ /// if the key is found in Items
+ /// and the associated value is of type ;
+ /// otherwise, the default value for .
+ /// This parameter is passed uninitialized.
+ ///
+ /// if the item is found and is of type ;
+ /// otherwise, .
+ /// is .
+ /// is .
+ public static bool TryGetItem(this IHttpContext @this, object key, out T value)
+ {
+ if (@this.Items.TryGetValue(key, out var item) && item is T typedItem)
+ {
+ value = typedItem;
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
+
+ /// Gets the item associated with the specified key.
+ /// The desired type of the item.
+ /// The on which this method is called.
+ /// The key whose value to get from the Items dictionary.
+ /// The item associated with the specified key,
+ /// if the key is found in Items
+ /// and the associated value is of type ;
+ /// otherwise, the default value for .
+ public static T GetItem(this IHttpContext @this, object key)
+ => @this.Items.TryGetValue(key, out var item) && item is T typedItem ? typedItem : default;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions-RequestStream.cs b/src/EmbedIO/HttpContextExtensions-RequestStream.cs
new file mode 100644
index 000000000..95c332b9b
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions-RequestStream.cs
@@ -0,0 +1,63 @@
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+using Swan.Logging;
+
+namespace EmbedIO
+{
+ partial class HttpContextExtensions
+ {
+ ///
+ /// Wraps the request input stream and returns a that can be used directly.
+ /// Decompression of compressed request bodies is implemented if specified in the web server's options.
+ ///
+ /// The on which this method is called.
+ ///
+ /// A that can be used to write response data.
+ /// This stream MUST be disposed when finished writing.
+ ///
+ ///
+ ///
+ public static Stream OpenRequestStream(this IHttpContext @this)
+ {
+ var stream = @this.Request.InputStream;
+
+ var encoding = @this.Request.Headers[HttpHeaderNames.ContentEncoding]?.Trim();
+ switch (encoding)
+ {
+ case CompressionMethodNames.Gzip:
+ if (@this.SupportCompressedRequests)
+ return new GZipStream(stream, CompressionMode.Decompress);
+ break;
+ case CompressionMethodNames.Deflate:
+ if (@this.SupportCompressedRequests)
+ return new DeflateStream(stream, CompressionMode.Decompress);
+ break;
+ case CompressionMethodNames.None:
+ case null:
+ return stream;
+ }
+
+ $"[{@this.Id}] Unsupported request content encoding \"{encoding}\", sending 400 Bad Request..."
+ .Warn(nameof(OpenRequestStream));
+
+ throw HttpException.BadRequest($"Unsupported content encoding \"{encoding}\"");
+ }
+
+ ///
+ /// Wraps the request input stream and returns a that can be used directly.
+ /// Decompression of compressed request bodies is implemented if specified in the web server's options.
+ /// If the request does not specify a content encoding,
+ /// UTF-8 is used by default.
+ ///
+ /// The on which this method is called.
+ ///
+ /// A that can be used to read the request body as text.
+ /// This reader MUST be disposed when finished reading.
+ ///
+ ///
+ ///
+ public static TextReader OpenRequestText(this IHttpContext @this)
+ => new StreamReader(OpenRequestStream(@this), @this.Request.ContentEncoding ?? Encoding.UTF8);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions-Requests.cs b/src/EmbedIO/HttpContextExtensions-Requests.cs
new file mode 100644
index 000000000..cce795a0f
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions-Requests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Specialized;
+using System.IO;
+using System.Runtime.ExceptionServices;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+
+namespace EmbedIO
+{
+ partial class HttpContextExtensions
+ {
+ private static readonly object FormDataKey = new object();
+ private static readonly object QueryDataKey = new object();
+
+ ///
+ /// Asynchronously retrieves the request body as an array of s.
+ ///
+ /// The on which this method is called.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be an array of s containing the request body.
+ /// is .
+ public static async Task GetRequestBodyAsByteArrayAsync(this IHttpContext @this)
+ {
+ using (var buffer = new MemoryStream())
+ using (var stream = @this.OpenRequestStream())
+ {
+ await stream.CopyToAsync(buffer, WebServer.StreamCopyBufferSize, @this.CancellationToken).ConfigureAwait(false);
+ return buffer.ToArray();
+ }
+ }
+
+ ///
+ /// Asynchronously buffers the request body into a read-only .
+ ///
+ /// The on which this method is called.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be a read-only containing the request body.
+ /// is .
+ public static async Task GetRequestBodyAsMemoryStreamAsync(this IHttpContext @this)
+ => new MemoryStream(
+ await GetRequestBodyAsByteArrayAsync(@this).ConfigureAwait(false),
+ false);
+
+ ///
+ /// Asynchronously retrieves the request body as a string.
+ ///
+ /// The on which this method is called.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be a representation of the request body.
+ /// is .
+ public static async Task GetRequestBodyAsStringAsync(this IHttpContext @this)
+ {
+ using (var reader = @this.OpenRequestText())
+ {
+ return await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Asynchronously deserializes a request body, using the default request deserializer.
+ /// As of EmbedIO version 3.0, the default response serializer has the same behavior of JSON
+ /// request parsing methods of version 2.
+ ///
+ /// The expected type of the deserialized data.
+ /// The on which this method is called.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be the deserialized data.
+ /// is .
+ public static Task GetRequestDataAsync(this IHttpContext @this)
+ => RequestDeserializer.Default(@this);
+
+ ///
+ /// Asynchronously deserializes a request body, using the specified request deserializer.
+ ///
+ /// The expected type of the deserialized data.
+ /// The on which this method is called.
+ /// A used to deserialize the request body.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be the deserialized data.
+ /// is .
+ /// is .
+ public static Task GetRequestDataAsync(this IHttpContext @this,RequestDeserializerCallback deserializer)
+ => Validate.NotNull(nameof(deserializer), deserializer)(@this);
+
+ ///
+ /// Asynchronously parses a request body in application/x-www-form-urlencoded format.
+ ///
+ /// The on which this method is called.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be a read-only of form field names and values.
+ /// is .
+ ///
+ /// This method may safely be called more than once for the same :
+ /// it will return the same collection instead of trying to parse the request body again.
+ ///
+ public static async Task GetRequestFormDataAsync(this IHttpContext @this)
+ {
+ if (!@this.Items.TryGetValue(FormDataKey, out var previousResult))
+ {
+ NameValueCollection result;
+ try
+ {
+ using (var reader = @this.OpenRequestText())
+ {
+ result = UrlEncodedDataParser.Parse(await reader.ReadToEndAsync().ConfigureAwait(false), false);
+ }
+ }
+ catch (Exception e)
+ {
+ @this.Items[FormDataKey] = e;
+ throw;
+ }
+
+ @this.Items[FormDataKey] = result;
+ return result;
+ }
+
+ switch (previousResult)
+ {
+ case NameValueCollection collection:
+ return collection;
+
+ case Exception exception:
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ return null;
+
+ case null:
+ SelfCheck.Fail($"Previous result of {nameof(HttpContextExtensions)}.{nameof(GetRequestFormDataAsync)} is null.");
+ return null;
+
+ default:
+ SelfCheck.Fail($"Previous result of {nameof(HttpContextExtensions)}.{nameof(GetRequestFormDataAsync)} is of unexpected type {previousResult.GetType().FullName}");
+ return null;
+ }
+ }
+
+ ///
+ /// Parses a request URL query. Note that this is different from getting the property,
+ /// in that fields without an equal sign are treated as if they have an empty value, instead of their keys being grouped
+ /// as values of the null key.
+ ///
+ /// The on which this method is called.
+ /// A read-only .
+ /// is .
+ ///
+ /// This method may safely be called more than once for the same :
+ /// it will return the same collection instead of trying to parse the request body again.
+ ///
+ public static NameValueCollection GetRequestQueryData(this IHttpContext @this)
+ {
+ if (!@this.Items.TryGetValue(QueryDataKey, out var previousResult))
+ {
+ NameValueCollection result;
+ try
+ {
+ result = UrlEncodedDataParser.Parse(@this.Request.Url.Query, false);
+ }
+ catch (Exception e)
+ {
+ @this.Items[FormDataKey] = e;
+ throw;
+ }
+
+ @this.Items[FormDataKey] = result;
+ return result;
+ }
+
+ switch (previousResult)
+ {
+ case NameValueCollection collection:
+ return collection;
+
+ case Exception exception:
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ return null;
+
+ case null:
+ SelfCheck.Fail($"Previous result of {nameof(HttpContextExtensions)}.{nameof(GetRequestQueryData)} is null.");
+ return null;
+
+ default:
+ SelfCheck.Fail($"Previous result of {nameof(HttpContextExtensions)}.{nameof(GetRequestQueryData)} is of unexpected type {previousResult.GetType().FullName}");
+ return null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions-ResponseStream.cs b/src/EmbedIO/HttpContextExtensions-ResponseStream.cs
new file mode 100644
index 000000000..efc244665
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions-ResponseStream.cs
@@ -0,0 +1,69 @@
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+using EmbedIO.Internal;
+
+namespace EmbedIO
+{
+ partial class HttpContextExtensions
+ {
+ ///
+ /// Wraps the response output stream and returns a that can be used directly.
+ /// Optional buffering is applied, so that the response may be sent as one instead of using chunked transfer.
+ /// Proactive negotiation is performed to select the best compression method supported by the client.
+ ///
+ /// The on which this method is called.
+ /// If set to , sent data is collected
+ /// in a and sent all at once when the returned
+ /// is disposed; if set to (the default), chunked transfer will be used.
+ /// if sending compressed data is preferred over
+ /// sending non-compressed data; otherwise, .
+ ///
+ /// A that can be used to write response data.
+ /// This stream MUST be disposed when finished writing.
+ ///
+ ///
+ public static Stream OpenResponseStream(this IHttpContext @this, bool buffered = false, bool preferCompression = true)
+ {
+ @this.Request.TryNegotiateContentEncoding(preferCompression, out var compressionMethod, out var prepareResponse);
+ prepareResponse(@this.Response); // The callback will throw HttpNotAcceptableException if negotiationSuccess is false.
+ var stream = buffered ? new BufferingResponseStream(@this.Response) : @this.Response.OutputStream;
+ switch (compressionMethod)
+ {
+ case CompressionMethod.Gzip:
+ return new GZipStream(stream, CompressionMode.Compress);
+ case CompressionMethod.Deflate:
+ return new DeflateStream(stream, CompressionMode.Compress);
+ default:
+ return stream;
+ }
+ }
+
+ ///
+ /// Wraps the response output stream and returns a that can be used directly.
+ /// Optional buffering is applied, so that the response may be sent as one instead of using chunked transfer.
+ /// Proactive negotiation is performed to select the best compression method supported by the client.
+ ///
+ /// The on which this method is called.
+ ///
+ /// The to use to convert text to data bytes.
+ /// If (the default), UTF-8 is used.
+ ///
+ /// If set to , sent data is collected
+ /// in a and sent all at once when the returned
+ /// is disposed; if set to (the default), chunked transfer will be used.
+ /// if sending compressed data is preferred over
+ /// sending non-compressed data; otherwise, .
+ ///
+ /// A that can be used to write response data.
+ /// This writer MUST be disposed when finished writing.
+ ///
+ ///
+ public static TextWriter OpenResponseText(this IHttpContext @this, Encoding encoding = null, bool buffered = false, bool preferCompression = true)
+ {
+ encoding = encoding ?? Encoding.UTF8;
+ @this.Response.ContentEncoding = encoding;
+ return new StreamWriter(OpenResponseStream(@this, buffered, preferCompression), encoding);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions-Responses.cs b/src/EmbedIO/HttpContextExtensions-Responses.cs
new file mode 100644
index 000000000..e1079431a
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions-Responses.cs
@@ -0,0 +1,148 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using EmbedIO.Utilities;
+
+namespace EmbedIO
+{
+ partial class HttpContextExtensions
+ {
+ private const string StandardHtmlHeaderFormat = "{0} - {1} {0} - {1} ";
+ private const string StandardHtmlFooter = "";
+
+ ///
+ /// Sets a redirection status code and adds a Location header to the response.
+ ///
+ /// The interface on which this method is called.
+ /// The URL to which the user agent should be redirected.
+ /// The status code to set on the response.
+ /// is .
+ /// is .
+ ///
+ /// is not a valid relative or absolute URL. .
+ /// - or -
+ /// is not a redirection (3xx) status code.
+ ///
+ public static void Redirect(this IHttpContext @this, string location, int statusCode = (int)HttpStatusCode.Found)
+ {
+ location = Validate.Url(nameof(location), location, @this.Request.Url);
+
+ if (statusCode < 300 || statusCode > 399)
+ throw new ArgumentException("Redirect status code is not valid.", nameof(statusCode));
+
+ @this.Response.SetEmptyResponse(statusCode);
+ @this.Response.Headers[HttpHeaderNames.Location] = location;
+ }
+
+ ///
+ /// Asynchronously sends a string as response.
+ ///
+ /// The interface on which this method is called.
+ /// The response content.
+ /// The MIME type of the content. If , the content type will not be set.
+ /// The to use.
+ /// A representing the ongoing operation.
+ /// is .
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ public static async Task SendStringAsync(
+ this IHttpContext @this,
+ string content,
+ string contentType,
+ Encoding encoding)
+ {
+ content = Validate.NotNull(nameof(content), content);
+ encoding = Validate.NotNull(nameof(encoding), encoding);
+
+ if (contentType != null)
+ {
+ @this.Response.ContentType = contentType;
+ @this.Response.ContentEncoding = encoding;
+ }
+
+ using (var text = @this.OpenResponseText(encoding))
+ await text.WriteAsync(content).ConfigureAwait(false);
+ }
+
+ ///
+ /// Asynchronously sends a standard HTML response for the specified status code.
+ ///
+ /// The interface on which this method is called.
+ /// The HTTP status code of the response.
+ /// A representing the ongoing operation.
+ /// is .
+ /// There is no standard status description for .
+ ///
+ public static Task SendStandardHtmlAsync(this IHttpContext @this, int statusCode)
+ => SendStandardHtmlAsync(@this, statusCode, null);
+
+ ///
+ /// Asynchronously sends a standard HTML response for the specified status code.
+ ///
+ /// The interface on which this method is called.
+ /// The HTTP status code of the response.
+ /// A callback function that may write additional HTML code
+ /// to a representing the response output.
+ /// If not , the callback is called immediately before closing the HTML body tag.
+ /// A representing the ongoing operation.
+ /// is .
+ /// There is no standard status description for .
+ ///
+ public static Task SendStandardHtmlAsync(
+ this IHttpContext @this,
+ int statusCode,
+ Action writeAdditionalHtml)
+ {
+ if (!HttpStatusDescription.TryGet(statusCode, out var statusDescription))
+ throw new ArgumentException("Status code has no standard description.", nameof(statusCode));
+
+ @this.Response.StatusCode = statusCode;
+ @this.Response.StatusDescription = statusDescription;
+ @this.Response.ContentType = MimeType.Html;
+ @this.Response.ContentEncoding = Encoding.UTF8;
+ using (var text = @this.OpenResponseText(Encoding.UTF8))
+ {
+ text.Write(StandardHtmlHeaderFormat, statusCode, statusDescription, Encoding.UTF8.WebName);
+ writeAdditionalHtml?.Invoke(text);
+ text.Write(StandardHtmlFooter);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Asynchronously sends serialized data as a response, using the default response serializer.
+ /// As of EmbedIO version 3.0, the default response serializer has the same behavior of JSON
+ /// response methods of version 2.
+ ///
+ /// The interface on which this method is called.
+ /// The data to serialize.
+ /// A representing the ongoing operation.
+ /// is .
+ ///
+ ///
+ public static Task SendDataAsync(this IHttpContext @this, object data)
+ => ResponseSerializer.Default(@this, data);
+
+ ///
+ /// Asynchronously sends serialized data as a response, using the specified response serializer.
+ /// As of EmbedIO version 3.0, the default response serializer has the same behavior of JSON
+ /// response methods of version 2.
+ ///
+ /// The interface on which this method is called.
+ /// A used to prepare the response.
+ /// The data to serialize.
+ /// A representing the ongoing operation.
+ /// is .
+ /// is .
+ ///
+ ///
+ public static Task SendDataAsync(this IHttpContext @this, ResponseSerializerCallback serializer, object data)
+ => Validate.NotNull(nameof(serializer), serializer)(@this, data);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpContextExtensions.cs b/src/EmbedIO/HttpContextExtensions.cs
new file mode 100644
index 000000000..4ffee4c7a
--- /dev/null
+++ b/src/EmbedIO/HttpContextExtensions.cs
@@ -0,0 +1,9 @@
+namespace EmbedIO
+{
+ ///
+ /// Provides extension methods for types implementing .
+ ///
+ public static partial class HttpContextExtensions
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpException-Shortcuts.cs b/src/EmbedIO/HttpException-Shortcuts.cs
new file mode 100644
index 000000000..08a3193fd
--- /dev/null
+++ b/src/EmbedIO/HttpException-Shortcuts.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net;
+
+namespace EmbedIO
+{
+ partial class HttpException
+ {
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 401 Unauthorized
+ /// response to the client.
+ ///
+ /// A message to include in the response.
+ /// The data object to include in the response.
+ ///
+ /// A newly-created .
+ ///
+ public static HttpException Unauthorized(string message = null, object data = null)
+ => new HttpException(HttpStatusCode.Unauthorized, message, data);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 403 Forbidden
+ /// response to the client.
+ ///
+ /// A message to include in the response.
+ /// The data object to include in the response.
+ /// A newly-created .
+ public static HttpException Forbidden(string message = null, object data = null)
+ => new HttpException(HttpStatusCode.Forbidden, message, data);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 400 Bad Request
+ /// response to the client.
+ ///
+ /// A message to include in the response.
+ /// The data object to include in the response.
+ /// A newly-created .
+ public static HttpException BadRequest(string message = null, object data = null)
+ => new HttpException(HttpStatusCode.BadRequest, message, data);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 404 Not Found
+ /// response to the client.
+ ///
+ /// A message to include in the response.
+ /// The data object to include in the response.
+ /// A newly-created .
+ public static HttpException NotFound(string message = null, object data = null)
+ => new HttpException(HttpStatusCode.NotFound, message, data);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 405 Method Not Allowed
+ /// response to the client.
+ ///
+ /// A message to include in the response.
+ /// The data object to include in the response.
+ /// A newly-created .
+ public static HttpException MethodNotAllowed(string message = null, object data = null)
+ => new HttpException(HttpStatusCode.MethodNotAllowed, message, data);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 406 Not Acceptable
+ /// response to the client.
+ ///
+ /// A newly-created .
+ ///
+ public static HttpNotAcceptableException NotAcceptable() => new HttpNotAcceptableException();
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 406 Not Acceptable
+ /// response to the client.
+ ///
+ /// A value, or a comma-separated list of values, to set the response's Vary header to.
+ /// A newly-created .
+ ///
+ public static HttpNotAcceptableException NotAcceptable(string vary) => new HttpNotAcceptableException(vary);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 416 Range Not Satisfiable
+ /// response to the client.
+ ///
+ /// A newly-created .
+ ///
+ public static HttpRangeNotSatisfiableException RangeNotSatisfiable() => new HttpRangeNotSatisfiableException();
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and send a 416 Range Not Satisfiable
+ /// response to the client.
+ ///
+ /// The total length of the requested resource, expressed in bytes,
+ /// or to omit the Content-Range header in the response.
+ /// A newly-created .
+ ///
+ public static HttpRangeNotSatisfiableException RangeNotSatisfiable(long? contentLength)
+ => new HttpRangeNotSatisfiableException(contentLength);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and redirect the client
+ /// to the specified location, using response status code 302.
+ ///
+ /// The redirection target.
+ ///
+ /// A newly-created .
+ ///
+ public static HttpRedirectException Redirect(string location)
+ => new HttpRedirectException(location);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and redirect the client
+ /// to the specified location, using the specified response status code.
+ ///
+ /// The redirection target.
+ /// The status code to set on the response, in the range from 300 to 399.
+ ///
+ /// A newly-created .
+ ///
+ /// is not in the 300-399 range.
+ public static HttpRedirectException Redirect(string location, int statusCode)
+ => new HttpRedirectException(location, statusCode);
+
+ ///
+ /// Returns a new instance of that, when thrown,
+ /// will break the request handling control flow and redirect the client
+ /// to the specified location, using the specified response status code.
+ ///
+ /// The redirection target.
+ /// One of the redirection status codes, to be set on the response.
+ ///
+ /// A newly-created .
+ ///
+ /// is not a redirection status code.
+ public static HttpRedirectException Redirect(string location, HttpStatusCode statusCode)
+ => new HttpRedirectException(location, statusCode);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpException.cs b/src/EmbedIO/HttpException.cs
new file mode 100644
index 000000000..c10bb2eb1
--- /dev/null
+++ b/src/EmbedIO/HttpException.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Net;
+
+namespace EmbedIO
+{
+ ///
+ /// When thrown, breaks the request handling control flow
+ /// and sends an error response to the client.
+ ///
+#pragma warning disable CA1032 // Implement standard exception constructors - they have no meaning here.
+ public partial class HttpException : Exception, IHttpException
+#pragma warning restore CA1032
+ {
+ ///
+ /// Initializes a new instance of the class,
+ /// with no message to include in the response.
+ ///
+ /// The status code to set on the response.
+ public HttpException(int statusCode)
+ {
+ StatusCode = statusCode;
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// with no message to include in the response.
+ ///
+ /// The status code to set on the response.
+ public HttpException(HttpStatusCode statusCode)
+ : this((int)statusCode)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// with a message to include in the response.
+ ///
+ /// The status code to set on the response.
+ /// A message to include in the response as plain text.
+ public HttpException(int statusCode, string message)
+ : base(message)
+ {
+ StatusCode = statusCode;
+ HttpExceptionMessage = message;
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// with a message to include in the response.
+ ///
+ /// The status code to set on the response.
+ /// A message to include in the response as plain text.
+ public HttpException(HttpStatusCode statusCode, string message)
+ : this((int)statusCode, message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// with a message and a data object to include in the response.
+ ///
+ /// The status code to set on the response.
+ /// A message to include in the response as plain text.
+ /// The data object to include in the response.
+ public HttpException(int statusCode, string message, object data)
+ : this(statusCode, message)
+ {
+ DataObject = data;
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// with a message and a data object to include in the response.
+ ///
+ /// The status code to set on the response.
+ /// A message to include in the response as plain text.
+ /// The data object to include in the response.
+ public HttpException(HttpStatusCode statusCode, string message, object data)
+ : this((int)statusCode, message, data)
+ {
+ }
+
+ ///
+ public int StatusCode { get; }
+
+ ///
+ public object DataObject { get; }
+
+ ///
+ string IHttpException.Message => HttpExceptionMessage;
+
+ // This property is necessary because when an exception with a null Message is thrown
+ // the CLR provides a standard message. We want null to remain null in IHttpException.
+ private string HttpExceptionMessage { get; }
+
+ ///
+ ///
+ /// This method does nothing; there is no need to call
+ /// base.PrepareResponse in overrides of this method.
+ ///
+ public virtual void PrepareResponse(IHttpContext context)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpExceptionHandler.cs b/src/EmbedIO/HttpExceptionHandler.cs
new file mode 100644
index 000000000..10dda1b19
--- /dev/null
+++ b/src/EmbedIO/HttpExceptionHandler.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Net;
+using System.Runtime.ExceptionServices;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using EmbedIO.Utilities;
+using Swan.Logging;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides standard handlers for HTTP exceptions at both module and server level.
+ ///
+ ///
+ /// Where applicable, HTTP exception handlers defined in this class
+ /// use the and
+ /// properties to customize
+ /// their behavior.
+ ///
+ ///
+ ///
+ public static class HttpExceptionHandler
+ {
+ ///
+ /// Gets the default handler used by .
+ /// This is the same as .
+ ///
+ public static HttpExceptionHandlerCallback Default { get; } = HtmlResponse;
+
+ ///
+ /// Sends an empty response.
+ ///
+ /// A interface representing the context of the request.
+ /// The HTTP exception.
+ /// A representing the ongoing operation.
+ public static Task EmptyResponse(IHttpContext context, IHttpException httpException)
+ => Task.CompletedTask;
+
+ ///
+ /// Sends a HTTP exception's Message property
+ /// as a plain text response.
+ /// This handler does not use the DataObject property.
+ ///
+ /// A interface representing the context of the request.
+ /// The HTTP exception.
+ /// A representing the ongoing operation.
+ public static Task PlainTextResponse(IHttpContext context, IHttpException httpException)
+ => context.SendStringAsync(httpException.Message, MimeType.PlainText, Encoding.UTF8);
+
+ ///
+ /// Sends a response with a HTML payload
+ /// briefly describing the error, including contact information and/or a stack trace
+ /// if specified via the
+ /// and properties, respectively.
+ /// This handler does not use the DataObject property.
+ ///
+ /// A interface representing the context of the request.
+ /// The HTTP exception.
+ /// A representing the ongoing operation.
+ public static Task HtmlResponse(IHttpContext context, IHttpException httpException)
+ => context.SendStandardHtmlAsync(
+ httpException.StatusCode,
+ text => {
+ text.Write(
+ "Exception type: {0}
Message: {1}",
+ HttpUtility.HtmlEncode(httpException.GetType().FullName ?? ""),
+ HttpUtility.HtmlEncode(httpException.Message));
+
+ text.Write("If this error is completely unexpected to you, and you think you should not seeing this page, please contact the server administrator");
+
+ if (!string.IsNullOrEmpty(ExceptionHandler.ContactInformation))
+ text.Write(" ({0})", HttpUtility.HtmlEncode(ExceptionHandler.ContactInformation));
+
+ text.Write(", informing them of the time this error occurred and the action(s) you performed that resulted in this error.
");
+
+ if (ExceptionHandler.IncludeStackTraces)
+ {
+ text.Write(
+ "
Stack trace:
{0} ",
+ HttpUtility.HtmlEncode(httpException.StackTrace));
+ }
+ });
+
+ ///
+ /// Gets a that will serialize a HTTP exception's
+ /// DataObject property and send it as a JSON response.
+ ///
+ /// A used to serialize data and send it to the client.
+ /// A .
+ /// is .
+ public static HttpExceptionHandlerCallback DataResponse(ResponseSerializerCallback serializerCallback)
+ {
+ Validate.NotNull(nameof(serializerCallback), serializerCallback);
+
+ return (context, httpException) => serializerCallback(context, httpException.DataObject);
+ }
+
+ ///
+ /// Gets a that will serialize a HTTP exception's
+ /// Message and DataObject properties
+ /// and send them as a JSON response.
+ /// The response will be a JSON object with a message property and a data property.
+ ///
+ /// A used to serialize data and send it to the client.
+ /// A .
+ /// is .
+ public static HttpExceptionHandlerCallback FullDataResponse(ResponseSerializerCallback serializerCallback)
+ {
+ Validate.NotNull(nameof(serializerCallback), serializerCallback);
+
+ return (context, httpException) => serializerCallback(context, new
+ {
+ message = httpException.Message,
+ data = httpException.DataObject,
+ });
+ }
+
+ internal static async Task Handle(string logSource, IHttpContext context, Exception exception, HttpExceptionHandlerCallback handler)
+ {
+ if (handler == null || !(exception is IHttpException httpException))
+ {
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ return;
+ }
+
+ exception.Log(logSource, $"[{context.Id}] HTTP exception {httpException.StatusCode}");
+
+ try
+ {
+ context.Response.SetEmptyResponse(httpException.StatusCode);
+ context.Response.DisableCaching();
+ httpException.PrepareResponse(context);
+ await handler(context, httpException)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (HttpListenerException)
+ {
+ throw;
+ }
+ catch (Exception exception2)
+ {
+ exception2.Log(logSource, $"[{context.Id}] Unhandled exception while handling HTTP exception {httpException.StatusCode}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpExceptionHandlerCallback.cs b/src/EmbedIO/HttpExceptionHandlerCallback.cs
new file mode 100644
index 000000000..372f1d520
--- /dev/null
+++ b/src/EmbedIO/HttpExceptionHandlerCallback.cs
@@ -0,0 +1,21 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// A callback used to build the contents of the response for an .
+ ///
+ /// A interface representing the context of the request.
+ /// An interface.
+ /// A representing the ongoing operation.
+ ///
+ /// When this delegate is called, the response's status code has already been set and the
+ /// method has already been called. The only thing left to do is preparing the response's content, according
+ /// to the property.
+ /// Any exception thrown by a handler (even a HTTP exception) will go unhandled: the web server
+ /// will not crash, but processing of the request will be aborted, and the response will be flushed as-is.
+ /// In other words, it is not a good ides to throw HttpException.NotFound() (or similar)
+ /// from a handler.
+ ///
+ public delegate Task HttpExceptionHandlerCallback(IHttpContext context, IHttpException httpException);
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/HttpHeaderNames.cs b/src/EmbedIO/HttpHeaderNames.cs
similarity index 97%
rename from src/Unosquare.Labs.EmbedIO/HttpHeaderNames.cs
rename to src/EmbedIO/HttpHeaderNames.cs
index 69157c5ae..a29846f2c 100644
--- a/src/Unosquare.Labs.EmbedIO/HttpHeaderNames.cs
+++ b/src/EmbedIO/HttpHeaderNames.cs
@@ -1,4 +1,4 @@
-namespace Unosquare.Labs.EmbedIO
+namespace EmbedIO
{
///
/// Exposes known HTTP header names.
@@ -296,7 +296,7 @@ public static class HttpHeaderNames
///
/// The incorrect spelling ("Referer" instead of "Referrer") is intentional
/// and has historical reasons.
- /// See the "Etimology" section of the Wikipedia article
+ /// See the "Etymology" section of the Wikipedia article
/// on this header for more information.
///
public const string Referer = "Referer";
@@ -307,27 +307,27 @@ public static class HttpHeaderNames
public const string RetryAfter = "Retry-After";
///
- /// The Sec-SystemWebSocket-Accept HTTP header.
+ /// The Sec-WebSocket-Accept HTTP header.
///
public const string SecWebSocketAccept = "Sec-WebSocket-Accept";
///
- /// The Sec-SystemWebSocket-Extensions HTTP header.
+ /// The Sec-WebSocket-Extensions HTTP header.
///
public const string SecWebSocketExtensions = "Sec-WebSocket-Extensions";
///
- /// The Sec-SystemWebSocket-Key HTTP header.
+ /// The Sec-WebSocket-Key HTTP header.
///
public const string SecWebSocketKey = "Sec-WebSocket-Key";
///
- /// The Sec-SystemWebSocket-Protocol HTTP header.
+ /// The Sec-WebSocket-Protocol HTTP header.
///
public const string SecWebSocketProtocol = "Sec-WebSocket-Protocol";
///
- /// The Sec-SystemWebSocket-Version HTTP header.
+ /// The Sec-WebSocket-Version HTTP header.
///
public const string SecWebSocketVersion = "Sec-WebSocket-Version";
@@ -446,4 +446,4 @@ public static class HttpHeaderNames
///
public const string XUACompatible = "X-UA-Compatible";
}
-}
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpListenerMode.cs b/src/EmbedIO/HttpListenerMode.cs
new file mode 100644
index 000000000..3e1010796
--- /dev/null
+++ b/src/EmbedIO/HttpListenerMode.cs
@@ -0,0 +1,20 @@
+namespace EmbedIO
+{
+ ///
+ /// Defines the HTTP listeners available for use in a .
+ ///
+ public enum HttpListenerMode
+ {
+ ///
+ /// Use EmbedIO's internal HTTP listener implementation,
+ /// based on Mono's System.Net.HttpListener .
+ ///
+ EmbedIO,
+
+ ///
+ /// Use the class
+ /// provided by the .NET runtime in use.
+ ///
+ Microsoft,
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpNotAcceptableException.cs b/src/EmbedIO/HttpNotAcceptableException.cs
new file mode 100644
index 000000000..117bfb944
--- /dev/null
+++ b/src/EmbedIO/HttpNotAcceptableException.cs
@@ -0,0 +1,55 @@
+using System.Net;
+
+namespace EmbedIO
+{
+ ///
+ /// When thrown, breaks the request handling control flow
+ /// and sends a redirection response to the client.
+ ///
+#pragma warning disable CA1032 // Implement standard exception constructors - they have no meaning here.
+ public class HttpNotAcceptableException : HttpException
+#pragma warning restore CA1032
+ {
+ ///
+ /// Initializes a new instance of the class,
+ /// without specifying a value for the response's Vary header.
+ ///
+ public HttpNotAcceptableException()
+ : this(null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// A value, or a comma-separated list of values, to set the response's Vary header to.
+ /// Although not specified in RFC7231 ,
+ /// this may help the client to understand why the request has been rejected.
+ /// If this parameter is or the empty string, the response's Vary header
+ /// is not set.
+ ///
+ public HttpNotAcceptableException(string vary)
+ : base((int)HttpStatusCode.NotAcceptable)
+ {
+ Vary = string.IsNullOrEmpty(vary) ? null : vary;
+ }
+
+ ///
+ /// Gets the value, or comma-separated list of values, to be set
+ /// on the response's Vary header.
+ ///
+ ///
+ /// If the empty string has been passed to the
+ /// constructor, the value of this property is .
+ ///
+ public string Vary { get; }
+
+ ///
+ public override void PrepareResponse(IHttpContext context)
+ {
+ if (Vary != null)
+ context.Response.Headers.Add(HttpHeaderNames.Vary, Vary);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpRangeNotSatisfiableException.cs b/src/EmbedIO/HttpRangeNotSatisfiableException.cs
new file mode 100644
index 000000000..8397e1be5
--- /dev/null
+++ b/src/EmbedIO/HttpRangeNotSatisfiableException.cs
@@ -0,0 +1,50 @@
+using System.Net;
+
+namespace EmbedIO
+{
+ ///
+ /// When thrown, breaks the request handling control flow
+ /// and sends a redirection response to the client.
+ ///
+#pragma warning disable CA1032 // Implement standard exception constructors - they have no meaning here.
+ public class HttpRangeNotSatisfiableException : HttpException
+#pragma warning restore CA1032
+ {
+ ///
+ /// Initializes a new instance of the class.
+ /// without specifying a value for the response's Content-Range header.
+ ///
+ public HttpRangeNotSatisfiableException()
+ : this(null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The total length of the requested resource, expressed in bytes,
+ /// or to omit the Content-Range header in the response.
+ public HttpRangeNotSatisfiableException(long? contentLength)
+ : base((int)HttpStatusCode.RequestedRangeNotSatisfiable)
+ {
+ ContentLength = contentLength;
+ }
+
+ ///
+ /// Gets the total content length to be specified
+ /// on the response's Content-Range header.
+ ///
+ public long? ContentLength { get; }
+
+ ///
+ public override void PrepareResponse(IHttpContext context)
+ {
+ // RFC 7233, Section 3.1: "When this status code is generated in response
+ // to a byte-range request, the sender
+ // SHOULD generate a Content-Range header field specifying
+ // the current length of the selected representation."
+ if (ContentLength.HasValue)
+ context.Response.Headers.Set(HttpHeaderNames.ContentRange, $"bytes */{ContentLength.Value}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpRedirectException.cs b/src/EmbedIO/HttpRedirectException.cs
new file mode 100644
index 000000000..22b680177
--- /dev/null
+++ b/src/EmbedIO/HttpRedirectException.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Net;
+
+namespace EmbedIO
+{
+ ///
+ /// When thrown, breaks the request handling control flow
+ /// and sends a redirection response to the client.
+ ///
+#pragma warning disable CA1032 // Implement standard exception constructors - they have no meaning here.
+ public class HttpRedirectException : HttpException
+#pragma warning restore CA1032
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The redirection target.
+ ///
+ /// The status code to set on the response, in the range from 300 to 399.
+ /// By default, status code 302 (Found ) is used.
+ ///
+ /// is not in the 300-399 range.
+ public HttpRedirectException(string location, int statusCode = (int)HttpStatusCode.Found)
+ : base(statusCode)
+ {
+ if (statusCode < 300 || statusCode > 399)
+ throw new ArgumentException("Redirect status code is not valid.", nameof(statusCode));
+
+ Location = location;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The redirection target.
+ /// One of the redirection status codes, to be set on the response.
+ /// is not a redirection status code.
+ public HttpRedirectException(string location, HttpStatusCode statusCode)
+ : this(location, (int)statusCode)
+ {
+ }
+
+ ///
+ /// Gets the URL where the client will be redirected.
+ ///
+ public string Location { get; }
+
+ ///
+ public override void PrepareResponse(IHttpContext context)
+ {
+ context.Redirect(Location, StatusCode);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpRequestExtensions.cs b/src/EmbedIO/HttpRequestExtensions.cs
new file mode 100644
index 000000000..4342806fb
--- /dev/null
+++ b/src/EmbedIO/HttpRequestExtensions.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.Http.Headers;
+using EmbedIO.Utilities;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides extension methods for types implementing .
+ ///
+ public static class HttpRequestExtensions
+ {
+ ///
+ /// Returns a string representing the remote IP address and port of an interface.
+ /// This method can be called even on a interface, or one that has no
+ /// remote end point, or no remote address; it will always return a non- ,
+ /// non-empty string.
+ ///
+ /// The on which this method is called.
+ ///
+ /// If is , or its RemoteEndPoint
+ /// is , the string "<null> ; otherwise, the remote end point's
+ /// Address (or the string "<???>" if it is )
+ /// followed by a colon and the Port number.
+ ///
+ public static string SafeGetRemoteEndpointStr(this IHttpRequest @this)
+ {
+ var endPoint = @this?.RemoteEndPoint;
+ return endPoint == null
+ ? ""
+ : $"{endPoint.Address?.ToString() ?? "??>"}:{endPoint.Port.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ ///
+ /// Attempts to proactively negotiate a compression method for a response,
+ /// based on a request's Accept-Encoding header (or lack of it).
+ ///
+ /// The on which this method is called.
+ /// if sending compressed data is preferred over
+ /// sending non-compressed data; otherwise, .
+ /// When this method returns, the compression method to use for the response,
+ /// if content negotiation is successful. This parameter is passed uninitialized.
+ /// When this method returns, a callback that prepares data in a
+ /// according to the result of content negotiation. This parameter is passed uninitialized.
+ /// if content negotiation is successful;
+ /// otherwise, .
+ ///
+ /// If this method returns , the callback
+ /// will set appropriate response headers to reflect the results of content negotiation.
+ /// If this method returns , the callback
+ /// will throw a to send a 406 Not Acceptable response
+ /// with the Vary header set to Accept-Encoding ,
+ /// so that the client may know the reason why the request has been rejected.
+ /// If has noAccept-Encoding header, this method
+ /// always returns and sets
+ /// to .
+ ///
+ ///
+ public static bool TryNegotiateContentEncoding(
+ this IHttpRequest @this,
+ bool preferCompression,
+ out CompressionMethod compressionMethod,
+ out Action prepareResponse)
+ {
+ var acceptedEncodings = new QValueList(true, @this.Headers.GetValues(HttpHeaderNames.AcceptEncoding));
+ if (!acceptedEncodings.TryNegotiateContentEncoding(preferCompression, out compressionMethod, out var compressionMethodName))
+ {
+ prepareResponse = r => throw HttpException.NotAcceptable(HttpHeaderNames.AcceptEncoding);
+ return false;
+ }
+
+ prepareResponse = r => {
+ r.Headers.Add(HttpHeaderNames.Vary, HttpHeaderNames.AcceptEncoding);
+ r.Headers.Set(HttpHeaderNames.ContentEncoding, compressionMethodName);
+ };
+ return true;
+ }
+
+ ///
+ /// Checks whether an If-None-Match header exists in a request
+ /// and, if so, whether it contains a given entity tag.
+ /// See RFC7232, Section 3.2
+ /// for a normative reference; however, see the Remarks section for more information
+ /// about the RFC compliance of this method.
+ ///
+ /// The on which this method is called.
+ /// The entity tag.
+ /// When this method returns, a value that indicates whether an
+ /// If-None-Match header is present in , regardless of the method's
+ /// return value. This parameter is passed uninitialized.
+ /// if an If-None-Match header is present in
+ /// and one of the entity tags listed in it is equal to ;
+ /// otherwise.
+ ///
+ /// RFC7232, Section 3.2
+ /// states that a weak comparison function (as defined in
+ /// RFC7232, Section 2.3.2 )
+ /// must be used for If-None-Match . That would mean parsing every entity tag, at least minimally,
+ /// to determine whether it is a "weak" or "strong" tag. Since EmbedIO currently generates only
+ /// "strong" tags, this method uses the default string comparer instead.
+ /// The behavior of this method is thus not, strictly speaking, RFC7232-compliant;
+ /// it works, though, with entity tags generated by EmbedIO.
+ ///
+ public static bool CheckIfNoneMatch(this IHttpRequest @this, string entityTag, out bool headerExists)
+ {
+ var values = @this.Headers.GetValues(HttpHeaderNames.IfNoneMatch);
+ if (values == null)
+ {
+ headerExists = false;
+ return false;
+ }
+
+ headerExists = true;
+ return values.Select(t => t.Trim()).Contains(entityTag);
+ }
+
+ // Check whether the If-Modified-Since request header exists
+ // and specifies a date and time more recent than or equal to
+ // the date and time of last modification of the requested resource.
+ // RFC7232, Section 3.3
+
+ ///
+ /// Checks whether an If-Modified-Since header exists in a request
+ /// and, if so, whether its value is a date and time more recent or equal to
+ /// a given .
+ /// See RFC7232, Section 3.3
+ /// for a normative reference.
+ ///
+ /// The on which this method is called.
+ /// A date and time value, in Coordinated Universal Time,
+ /// expressing the last time a resource was modified.
+ /// When this method returns, a value that indicates whether an
+ /// If-Modified-Since header is present in , regardless of the method's
+ /// return value. This parameter is passed uninitialized.
+ /// if an If-Modified-Since header is present in
+ /// and its value is a date and time more recent or equal to ;
+ /// otherwise.
+ public static bool CheckIfModifiedSince(this IHttpRequest @this, DateTime lastModifiedUtc, out bool headerExists)
+ {
+ var value = @this.Headers.Get(HttpHeaderNames.IfModifiedSince);
+ if (value == null)
+ {
+ headerExists = false;
+ return false;
+ }
+
+ headerExists = true;
+ return HttpDate.TryParse(value, out var dateTime)
+ && dateTime.UtcDateTime >= lastModifiedUtc;
+ }
+
+ // Checks the Range request header to tell whether to send
+ // a "206 Partial Content" response.
+
+ ///
+ /// Checks whether a Range header exists in a request
+ /// and, if so, determines whether it is possible to send a 206 Partial Content response.
+ /// See RFC7233
+ /// for a normative reference; however, see the Remarks section for more information
+ /// about the RFC compliance of this method.
+ ///
+ /// The on which this method is called.
+ /// The total length, in bytes, of the response entity, i.e.
+ /// what would be sent in a 200 OK response.
+ /// An entity tag representing the response entity. This value is checked against
+ /// the If-Range header, if it is present.
+ /// The date and time value, in Coordinated Universal Time,
+ /// expressing the last modification time of the resource entity. This value is checked against
+ /// the If-Range header, if it is present.
+ /// When this method returns , the start of the requested byte range.
+ /// This parameter is passed uninitialized.
+ ///
+ /// When this method returns , the upper bound of the requested byte range.
+ /// This parameter is passed uninitialized.
+ /// Note that the upper bound of a range is NOT the sum of the range's start and length;
+ /// for example, a range expressed as bytes=0-99 has a start of 0, an upper bound of 99,
+ /// and a length of 100 bytes.
+ ///
+ ///
+ /// This method returns if the following conditions are satisfied:
+ ///
+ /// >the request's HTTP method is GET ;
+ /// >a Range header is present in the request;
+ /// >either no If-Range header is present in the request, or it
+ /// specifies an entity tag equal to , or a UTC date and time
+ /// equal to ;
+ /// >the Range header specifies exactly one range;
+ /// >the specified range is entirely contained in the range from 0 to - 1.
+ ///
+ /// If the last condition is not satisfied, i.e. the specified range start and/or upper bound
+ /// are out of the range from 0 to - 1, this method does not return;
+ /// it throws a instead.
+ /// If any of the other conditions are not satisfied, this method returns .
+ ///
+ ///
+ /// According to RFC7233, Section 3.1 ,
+ /// there are several conditions under which a server may ignore or reject a range request; therefore,
+ /// clients are (or should be) prepared to receive a 200 OK response with the whole response
+ /// entity instead of the requested range(s). For this reason, until the generation of
+ /// multipart/byteranges responses is implemented in EmbedIO, this method will ignore
+ /// range requests specifying more than one range, even if this behavior is not, strictly speaking,
+ /// RFC7233-compliant.
+ /// To make clients aware that range requests are accepted for a resource, every 200 OK
+ /// (or 304 Not Modified ) response for the same resource should include an Accept-Ranges
+ /// header with the string bytes as value.
+ ///
+ public static bool IsRangeRequest(this IHttpRequest @this, long contentLength, string entityTag, DateTime lastModifiedUtc, out long start, out long upperBound)
+ {
+ start = 0;
+ upperBound = contentLength - 1;
+
+ // RFC7233, Section 3.1:
+ // "A server MUST ignore a Range header field received with a request method other than GET."
+ if (@this.HttpVerb != HttpVerbs.Get)
+ return false;
+
+ // No Range header, no partial content.
+ var rangeHeader = @this.Headers.Get(HttpHeaderNames.Range);
+ if (rangeHeader == null)
+ return false;
+
+ // Ignore the Range header if there is no If-Range header
+ // or if the If-Range header specifies a non-matching validator.
+ // RFC7233, Section 3.2: "If the validator given in the If-Range header field matches the
+ // current validator for the selected representation of the target
+ // resource, then the server SHOULD process the Range header field as
+ // requested.If the validator does not match, the server MUST ignore
+ // the Range header field.Note that this comparison by exact match,
+ // including when the validator is an HTTP-date, differs from the
+ // "earlier than or equal to" comparison used when evaluating an
+ // If-Unmodified-Since conditional."
+ var ifRange = @this.Headers.Get(HttpHeaderNames.IfRange)?.Trim();
+ if (ifRange != null && ifRange != entityTag)
+ {
+ if (!HttpDate.TryParse(ifRange, out var rangeDate))
+ return false;
+
+ if (rangeDate.UtcDateTime != lastModifiedUtc)
+ return false;
+ }
+
+ // Ignore the Range request header if it cannot be parsed successfully.
+ if (!RangeHeaderValue.TryParse(rangeHeader, out var range))
+ return false;
+
+ // EmbedIO does not support multipart/byteranges responses (yet),
+ // thus ignore range requests that specify one range.
+ if (range.Ranges.Count != 1)
+ return false;
+
+ var firstRange = range.Ranges.First();
+ start = firstRange.From ?? 0L;
+ upperBound = firstRange.To ?? contentLength - 1;
+ if (start >= contentLength || upperBound < start || upperBound >= contentLength)
+ throw HttpException.RangeNotSatisfiable(contentLength);
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/HttpResponseExtensions.cs b/src/EmbedIO/HttpResponseExtensions.cs
new file mode 100644
index 000000000..2ed1443ac
--- /dev/null
+++ b/src/EmbedIO/HttpResponseExtensions.cs
@@ -0,0 +1,43 @@
+using System;
+using EmbedIO.Utilities;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides extension methods for types implementing .
+ ///
+ public static class HttpResponseExtensions
+ {
+ ///
+ /// Sets the necessary headers to disable caching of a response on the client side.
+ ///
+ /// The interface on which this method is called.
+ /// is .
+ public static void DisableCaching(this IHttpResponse @this)
+ {
+ var headers = @this.Headers;
+ headers.Set(HttpHeaderNames.Expires, "Sat, 26 Jul 1997 05:00:00 GMT");
+ headers.Set(HttpHeaderNames.LastModified, HttpDate.Format(DateTime.UtcNow));
+ headers.Set(HttpHeaderNames.CacheControl, "no-store, no-cache, must-revalidate");
+ headers.Add(HttpHeaderNames.Pragma, "no-cache");
+ }
+
+ ///
+ /// Prepares a standard response without a body for the specified status code.
+ ///
+ /// The interface on which this method is called.
+ /// The HTTP status code of the response.
+ /// is .
+ /// There is no standard status description for .
+ public static void SetEmptyResponse(this IHttpResponse @this, int statusCode)
+ {
+ if (!HttpStatusDescription.TryGet(statusCode, out var statusDescription))
+ throw new ArgumentException("Status code has no standard description.", nameof(statusCode));
+
+ @this.StatusCode = statusCode;
+ @this.StatusDescription = statusDescription;
+ @this.ContentType = string.Empty;
+ @this.ContentEncoding = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/HttpStatusDescription.cs b/src/EmbedIO/HttpStatusDescription.cs
similarity index 98%
rename from src/Unosquare.Labs.EmbedIO/HttpStatusDescription.cs
rename to src/EmbedIO/HttpStatusDescription.cs
index 6fd40069c..a724005fd 100644
--- a/src/Unosquare.Labs.EmbedIO/HttpStatusDescription.cs
+++ b/src/EmbedIO/HttpStatusDescription.cs
@@ -1,8 +1,8 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System.Collections.Generic;
- using System.Net;
+using System.Collections.Generic;
+using System.Net;
+namespace EmbedIO
+{
///
/// Provides standard HTTP status descriptions.
/// Data contained in this class comes from the following sources:
@@ -143,4 +143,4 @@ public static string Get(int code)
return description;
}
}
-}
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/Constants/HttpVerbs.cs b/src/EmbedIO/HttpVerbs.cs
similarity index 94%
rename from src/Unosquare.Labs.EmbedIO/Constants/HttpVerbs.cs
rename to src/EmbedIO/HttpVerbs.cs
index 4d8186f8e..765b01eb0 100644
--- a/src/Unosquare.Labs.EmbedIO/Constants/HttpVerbs.cs
+++ b/src/EmbedIO/HttpVerbs.cs
@@ -1,4 +1,4 @@
-namespace Unosquare.Labs.EmbedIO.Constants
+namespace EmbedIO
{
///
/// Enumerates the different HTTP Verbs.
@@ -45,4 +45,4 @@ public enum HttpVerbs
///
Put,
}
-}
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ICookieCollection.cs b/src/EmbedIO/ICookieCollection.cs
new file mode 100644
index 000000000..cbf27a3d5
--- /dev/null
+++ b/src/EmbedIO/ICookieCollection.cs
@@ -0,0 +1,49 @@
+using System.Net;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace EmbedIO
+{
+ ///
+ /// Interface for Cookie Collection.
+ ///
+ ///
+#pragma warning disable CA1010 // Should implement ICollection - not possible when wrapping System.Net.CookieCollection.
+ public interface ICookieCollection : IEnumerable, ICollection
+#pragma warning restore CA1010
+ {
+ ///
+ /// Gets the with the specified name.
+ ///
+ ///
+ /// The .
+ ///
+ /// The name.
+ /// The cookie matching the specified name.
+ Cookie this[string name] { get; }
+
+ ///
+ /// Determines whether this contains the specified .
+ ///
+ /// The cookie to find in the .
+ ///
+ /// if this contains the specified ;
+ /// otherwise, .
+ ///
+ bool Contains(Cookie cookie);
+
+ ///
+ /// Copies the elements of this to a array
+ /// starting at the specified index of the target array.
+ ///
+ /// The target array to which the will be copied.
+ /// The zero-based index in the target where copying begins.
+ void CopyTo(Cookie[] array, int index);
+
+ ///
+ /// Adds the specified cookie.
+ ///
+ /// The cookie.
+ void Add(Cookie cookie);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IHttpContext.cs b/src/EmbedIO/IHttpContext.cs
new file mode 100644
index 000000000..bed769534
--- /dev/null
+++ b/src/EmbedIO/IHttpContext.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Routing;
+using EmbedIO.Sessions;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents the context of a HTTP(s) request being handled by a web server.
+ ///
+ public interface IHttpContext : IMimeTypeProvider
+ {
+ ///
+ /// Gets a unique identifier for a HTTP context.
+ ///
+ string Id { get; }
+
+ ///
+ /// Gets a used to stop processing of this context.
+ ///
+ CancellationToken CancellationToken { get; }
+
+ ///
+ /// Gets the server IP address and port number to which the request is directed.
+ ///
+ IPEndPoint LocalEndPoint { get; }
+
+ ///
+ /// Gets the client IP address and port number from which the request originated.
+ ///
+ IPEndPoint RemoteEndPoint { get; }
+
+ ///
+ /// Gets the HTTP request.
+ ///
+ IHttpRequest Request { get; }
+
+ ///
+ /// Gets the route matched by the requested URL path.
+ ///
+ RouteMatch Route { get; }
+
+ ///
+ /// Gets the requested path, relative to the innermost module's base path.
+ ///
+ ///
+ /// This property derives from the path specified in the requested URL, stripped of the
+ /// BaseRoute of the handling module.
+ /// This property is in itself a valid URL path, including an initial
+ /// slash (/ ) character.
+ ///
+ string RequestedPath { get; }
+
+ ///
+ /// Gets the HTTP response object.
+ ///
+ IHttpResponse Response { get; }
+
+ ///
+ /// Gets the user.
+ ///
+ IPrincipal User { get; }
+
+ ///
+ /// Gets the session proxy associated with this context.
+ ///
+ ISessionProxy Session { get; }
+
+ ///
+ /// Gets a value indicating whether compressed request bodies are supported.
+ ///
+ ///
+ bool SupportCompressedRequests { get; }
+
+ ///
+ /// Gets the dictionary of data to pass trough the EmbedIO pipeline.
+ ///
+ IDictionary Items { get; }
+
+ ///
+ /// Gets the elapsed time, expressed in milliseconds, since the creation of this context.
+ ///
+ long Age { get; }
+
+ ///
+ /// Gets a value indicating whether this
+ /// has been completely handled, so that no further processing is required.
+ /// When a HTTP context is created, this property is ;
+ /// as soon as it is set to , the context is not
+ /// passed to any further module's handler for processing.
+ /// Once it becomes , this property is guaranteed
+ /// to never become again.
+ ///
+ ///
+ /// When a module's IsFinalHandler property is
+ /// , this property is set to after the
+ /// returned by the module's HandleRequestAsync method
+ /// is completed.
+ ///
+ ///
+ ///
+ bool IsHandled { get; }
+
+ ///
+ /// Marks this context as handled, so that it will not be
+ /// processed by any further module.
+ ///
+ ///
+ /// Calling this method from the
+ /// or of a module whose
+ /// property is
+ /// is redundant and has no effect.
+ ///
+ ///
+ ///
+ void SetHandled();
+
+ ///
+ /// Registers a callback to be called when processing is finished on a context.
+ ///
+ /// The callback.
+ void OnClose(Action callback);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IHttpContextHandler.cs b/src/EmbedIO/IHttpContextHandler.cs
new file mode 100644
index 000000000..ed0f311c7
--- /dev/null
+++ b/src/EmbedIO/IHttpContextHandler.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents an object that can handle a HTTP context.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ public interface IHttpContextHandler
+ {
+ ///
+ /// Asynchronously handles a HTTP context, generating a suitable response
+ /// for an incoming request.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ /// The HTTP context.
+ /// A representing the ongoing operation.
+ Task HandleContextAsync(IHttpContextImpl context);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IHttpContextImpl.cs b/src/EmbedIO/IHttpContextImpl.cs
new file mode 100644
index 000000000..d6e347b9a
--- /dev/null
+++ b/src/EmbedIO/IHttpContextImpl.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Routing;
+using EmbedIO.Sessions;
+using EmbedIO.Utilities;
+using EmbedIO.WebSockets;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents a HTTP context implementation, i.e. a HTTP context as seen internally by EmbedIO.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ ///
+ public interface IHttpContextImpl : IHttpContext
+ {
+ ///
+ /// Gets or sets a used to stop processing of this context.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ new CancellationToken CancellationToken { get; set; }
+
+ ///
+ /// Gets or sets the route matched by the requested URL path.
+ ///
+ RouteMatch Route { get; set; }
+
+ ///
+ /// Gets or sets the session proxy associated with this context.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ ///
+ /// A interface.
+ ///
+ new ISessionProxy Session { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether compressed request bodies are supported.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ ///
+ new bool SupportCompressedRequests { get; set; }
+
+ ///
+ /// Gets the MIME type providers.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ MimeTypeProviderStack MimeTypeProviders { get; }
+
+ ///
+ /// Flushes and closes the response stream, then calls any registered close callbacks.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ ///
+ void Close();
+
+ ///
+ /// Asynchronously handles a WebSockets opening handshake
+ /// and returns a newly-created interface.
+ /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code.
+ ///
+ /// The requested WebSocket sub-protocols.
+ /// The accepted WebSocket sub-protocol.
+ /// Size of the receive buffer.
+ /// The keep-alive interval.
+ /// A used to stop the server.
+ ///
+ /// A interface.
+ ///
+ Task AcceptWebSocketAsync(
+ IEnumerable requestedProtocols,
+ string acceptedProtocol,
+ int receiveBufferSize,
+ TimeSpan keepAliveInterval,
+ CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IHttpException.cs b/src/EmbedIO/IHttpException.cs
new file mode 100644
index 000000000..32656b1e4
--- /dev/null
+++ b/src/EmbedIO/IHttpException.cs
@@ -0,0 +1,58 @@
+using System;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents an exception that results in a particular
+ /// HTTP response to be sent to the client.
+ /// This interface is meant to be implemented
+ /// by classes derived from .
+ /// Either as message or a data object can be attached to
+ /// the exception; which one, if any, is sent to the client
+ /// will depend upon the handler used to send the response.
+ ///
+ ///
+ ///
+ public interface IHttpException
+ {
+ ///
+ /// Gets the response status code for a HTTP exception.
+ ///
+ int StatusCode { get; }
+
+ ///
+ /// Gets the stack trace of a HTTP exception.
+ ///
+ string StackTrace { get; }
+
+ ///
+ /// Gets a message that can be included in the response triggered
+ /// by a HTTP exception.
+ /// Whether the message is actually sent to the client will depend
+ /// upon the handler used to send the response.
+ ///
+ ///
+ /// Do not rely on to implement
+ /// this property if you want to support messages,
+ /// because a default message will be supplied by the CLR at throw time
+ /// when is .
+ ///
+ string Message { get; }
+
+ ///
+ /// Gets an object that can be serialized and included
+ /// in the response triggered by a HTTP exception.
+ /// Whether the object is actually sent to the client will depend
+ /// upon the handler used to send the response.
+ ///
+ object DataObject { get; }
+
+ ///
+ /// Sets necessary headers, as required by the nature
+ /// of the HTTP exception (e.g. Location for
+ /// ).
+ ///
+ /// The HTTP context of the response.
+ void PrepareResponse(IHttpContext context);
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpListener.cs b/src/EmbedIO/IHttpListener.cs
similarity index 85%
rename from src/Unosquare.Labs.EmbedIO/Abstractions/IHttpListener.cs
rename to src/EmbedIO/IHttpListener.cs
index 89ee3da0a..ab03c11af 100644
--- a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpListener.cs
+++ b/src/EmbedIO/IHttpListener.cs
@@ -1,10 +1,10 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System;
- using System.Collections.Generic;
- using System.Threading;
- using System.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+namespace EmbedIO
+{
///
/// Interface to create a HTTP Listener.
///
@@ -61,10 +61,10 @@ public interface IHttpListener : IDisposable
///
/// Gets the HTTP context asynchronous.
///
- /// The cancellation token.
+ /// The cancellation token.
///
/// A task that represents the time delay for the HTTP Context.
///
- Task GetContextAsync(CancellationToken ct);
+ Task GetContextAsync(CancellationToken cancellationToken);
}
}
diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpBase.cs b/src/EmbedIO/IHttpMessage.cs
similarity index 54%
rename from src/Unosquare.Labs.EmbedIO/Abstractions/IHttpBase.cs
rename to src/EmbedIO/IHttpMessage.cs
index bd0e20a02..f66447662 100644
--- a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpBase.cs
+++ b/src/EmbedIO/IHttpMessage.cs
@@ -1,21 +1,12 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System;
- using System.Collections.Specialized;
+using System;
+namespace EmbedIO
+{
///
- /// Interface to create a HTTP Request/Response.
+ /// Represents a HTTP request or response.
///
- public interface IHttpBase
+ public interface IHttpMessage
{
- ///
- /// Gets the headers.
- ///
- ///
- /// The headers.
- ///
- NameValueCollection Headers { get; }
-
///
/// Gets the cookies.
///
diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpRequest.cs b/src/EmbedIO/IHttpRequest.cs
similarity index 52%
rename from src/Unosquare.Labs.EmbedIO/Abstractions/IHttpRequest.cs
rename to src/EmbedIO/IHttpRequest.cs
index a24c36340..ab6c54043 100644
--- a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpRequest.cs
+++ b/src/EmbedIO/IHttpRequest.cs
@@ -1,158 +1,115 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System.Text;
- using System.IO;
- using System.Collections.Specialized;
- using System;
+using System;
+using System.Collections.Specialized;
+using System.IO;
+using System.Net;
+using System.Text;
+namespace EmbedIO
+{
///
///
/// Interface to create a HTTP Request.
///
- public interface IHttpRequest : IHttpBase
+ public interface IHttpRequest : IHttpMessage
{
+ ///
+ /// Gets the request headers.
+ ///
+ NameValueCollection Headers { get; }
+
///
/// Gets a value indicating whether [keep alive].
///
- ///
- /// true if [keep alive]; otherwise, false .
- ///
bool KeepAlive { get; }
///
/// Gets the raw URL.
///
- ///
- /// The raw URL.
- ///
string RawUrl { get; }
///
/// Gets the query string.
///
- ///
- /// The query string.
- ///
NameValueCollection QueryString { get; }
///
/// Gets the HTTP method.
///
- ///
- /// The HTTP method.
- ///
string HttpMethod { get; }
+ ///
+ /// Gets a constant representing the HTTP method of the request.
+ ///
+ HttpVerbs HttpVerb { get; }
+
///
/// Gets the URL.
///
- ///
- /// The URL.
- ///
Uri Url { get; }
///
/// Gets a value indicating whether this instance has entity body.
///
- ///
- /// true if this instance has entity body; otherwise, false .
- ///
bool HasEntityBody { get; }
///
/// Gets the input stream.
///
- ///
- /// The input stream.
- ///
Stream InputStream { get; }
///
/// Gets the content encoding.
///
- ///
- /// The content encoding.
- ///
Encoding ContentEncoding { get; }
///
/// Gets the remote end point.
///
- ///
- /// The remote end point.
- ///
- System.Net.IPEndPoint RemoteEndPoint { get; }
+ IPEndPoint RemoteEndPoint { get; }
///
/// Gets a value indicating whether this instance is local.
///
- ///
- /// true if this instance is local; otherwise, false .
- ///
bool IsLocal { get; }
+ ///
+ /// Gets a value indicating whether this request has been received over a SSL connection.
+ ///
+ bool IsSecureConnection { get; }
+
///
/// Gets the user agent.
///
- ///
- /// The user agent.
- ///
string UserAgent { get; }
///
/// Gets a value indicating whether this instance is web socket request.
///
- ///
- /// true if this instance is web socket request; otherwise, false .
- ///
bool IsWebSocketRequest { get; }
///
/// Gets the local end point.
///
- ///
- /// The local end point.
- ///
- System.Net.IPEndPoint LocalEndPoint { get; }
+ IPEndPoint LocalEndPoint { get; }
///
/// Gets the type of the content.
///
- ///
- /// The type of the content.
- ///
string ContentType { get; }
///
- /// Gets the content length64.
+ /// Gets the content length.
///
- ///
- /// The content length64.
- ///
long ContentLength64 { get; }
///
/// Gets a value indicating whether this instance is authenticated.
///
- ///
- /// true if this instance is authenticated; otherwise, false .
- ///
bool IsAuthenticated { get; }
///
/// Gets the URL referrer.
///
- ///
- /// The URL referrer.
- ///
Uri UrlReferrer { get; }
-
- ///
- /// Gets the request identifier of the incoming HTTP request.
- ///
- ///
- /// The request trace identifier.
- ///
- Guid RequestTraceIdentifier { get; }
}
-}
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpResponse.cs b/src/EmbedIO/IHttpResponse.cs
similarity index 53%
rename from src/Unosquare.Labs.EmbedIO/Abstractions/IHttpResponse.cs
rename to src/EmbedIO/IHttpResponse.cs
index 169a9cb7b..7d75cf5ff 100644
--- a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpResponse.cs
+++ b/src/EmbedIO/IHttpResponse.cs
@@ -1,82 +1,65 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System.Text;
- using System.IO;
+using System.IO;
+using System.Net;
+using System.Text;
+namespace EmbedIO
+{
///
///
/// Interface to create a HTTP Response.
///
- public interface IHttpResponse : IHttpBase
+ public interface IHttpResponse : IHttpMessage
{
+ ///
+ /// Gets the response headers.
+ ///
+ WebHeaderCollection Headers { get; }
+
///
/// Gets or sets the status code.
///
- ///
- /// The status code.
- ///
int StatusCode { get; set; }
///
- /// Gets or sets the content length64.
+ /// Gets or sets the content length.
///
- ///
- /// The content length64.
- ///
long ContentLength64 { get; set; }
///
/// Gets or sets the type of the content.
///
- ///
- /// The type of the content.
- ///
string ContentType { get; set; }
///
/// Gets the output stream.
///
- ///
- /// The output stream.
- ///
Stream OutputStream { get; }
///
/// Gets or sets the content encoding.
///
- ///
- /// The content encoding.
- ///
- Encoding ContentEncoding { get; }
+ Encoding ContentEncoding { get; set; }
///
/// Gets or sets a value indicating whether [keep alive].
///
- ///
- /// true if [keep alive]; otherwise, false .
- ///
bool KeepAlive { get; set; }
///
- /// Gets or sets a text description of the HTTP status code.
+ /// Gets or sets a value indicating whether the response uses chunked transfer encoding.
///
- ///
- /// The status description.
- ///
- string StatusDescription { get; set; }
+ bool SendChunked { get; set; }
///
- /// Adds the header.
+ /// Gets or sets a text description of the HTTP status code.
///
- /// Name of the header.
- /// The value.
- void AddHeader(string headerName, string value);
+ string StatusDescription { get; set; }
///
/// Sets the cookie.
///
- /// The session cookie.
- void SetCookie(System.Net.Cookie sessionCookie);
+ /// The session cookie.
+ void SetCookie(Cookie cookie);
///
/// Closes this instance and dispose the resources.
diff --git a/src/EmbedIO/IMimeTypeCustomizer.cs b/src/EmbedIO/IMimeTypeCustomizer.cs
new file mode 100644
index 000000000..ff9c70cf0
--- /dev/null
+++ b/src/EmbedIO/IMimeTypeCustomizer.cs
@@ -0,0 +1,45 @@
+using System;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents an object that can set information about specific MIME types and media ranges,
+ /// to be later retrieved via an interface.
+ ///
+ ///
+ public interface IMimeTypeCustomizer : IMimeTypeProvider
+ {
+ ///
+ /// Adds a custom association between a file extension and a MIME type.
+ ///
+ /// The file extension to associate to .
+ /// The MIME type to associate to .
+ /// The object implementing
+ /// has its configuration locked.
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ ///
+ /// is the empty string.
+ /// - or -
+ /// is not a valid MIME type.
+ ///
+ void AddCustomMimeType(string extension, string mimeType);
+
+ ///
+ /// Indicates whether to prefer compression when negotiating content encoding
+ /// for a response with the specified content type, or whose content type is in
+ /// the specified media range.
+ ///
+ /// The MIME type or media range.
+ /// to prefer compression;
+ /// otherwise, .
+ /// The object implementing
+ /// has its configuration locked.
+ /// is .
+ /// is not a valid MIME type or media range.
+ void PreferCompression(string mimeType, bool preferCompression);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IMimeTypeProvider.cs b/src/EmbedIO/IMimeTypeProvider.cs
new file mode 100644
index 000000000..6d9458d49
--- /dev/null
+++ b/src/EmbedIO/IMimeTypeProvider.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents an object that contains information on specific MIME types and media ranges.
+ ///
+ public interface IMimeTypeProvider
+ {
+ ///
+ /// Gets the MIME type associated to a file extension.
+ ///
+ /// The file extension for which a corresponding MIME type is wanted.
+ /// The MIME type corresponding to , if one is found;
+ /// otherwise, .
+ /// is .
+ string GetMimeType(string extension);
+
+ ///
+ /// Attempts to determine whether compression should be preferred
+ /// when negotiating content encoding for a response with the specified content type.
+ ///
+ /// The MIME type to check.
+ /// When this method returns ,
+ /// a value indicating whether compression should be preferred.
+ /// This parameter is passed uninitialized.
+ /// if a value is found for ;
+ /// otherwise, .
+ bool TryDetermineCompression(string mimeType, out bool preferCompression);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IWebModule.cs b/src/EmbedIO/IWebModule.cs
new file mode 100644
index 000000000..b4e2083ce
--- /dev/null
+++ b/src/EmbedIO/IWebModule.cs
@@ -0,0 +1,81 @@
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Routing;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents a module.
+ ///
+ public interface IWebModule
+ {
+ ///
+ /// Gets the base route of a module.
+ ///
+ ///
+ /// The base route.
+ ///
+ ///
+ /// A base route is either "/" (the root path),
+ /// or a prefix starting and ending with a '/' character.
+ ///
+ string BaseRoute { get; }
+
+ ///
+ /// Gets a value indicating whether processing of a request should stop
+ /// after a module has handled it.
+ ///
+ ///
+ /// If this property is , a HTTP context's
+ /// method will be automatically called
+ /// immediately after after the returned by
+ /// is completed. This will prevent
+ /// the context from being passed further along to other modules.
+ ///
+ ///
+ ///
+ bool IsFinalHandler { get; }
+
+ ///
+ /// Gets or sets a callback that is called every time an unhandled exception
+ /// occurs during the processing of a request.
+ /// If this property is (the default),
+ /// the exception will be handled by the web server, or by the containing
+ /// .
+ ///
+ ///
+ ExceptionHandlerCallback OnUnhandledException { get; set; }
+
+ ///
+ /// Gets or sets a callback that is called every time a HTTP exception
+ /// is thrown during the processing of a request.
+ /// If this property is (the default),
+ /// the exception will be handled by the web server, or by the containing
+ /// .
+ ///
+ ///
+ HttpExceptionHandlerCallback OnHttpException { get; set; }
+
+ ///
+ /// Signals a module that the web server is starting.
+ ///
+ /// A used to stop the web server.
+ void Start(CancellationToken cancellationToken);
+
+ ///
+ /// Matches the specified URL path against a module's ,
+ /// extracting values for the route's parameters and a sub-path.
+ ///
+ /// The URL path to match.
+ /// If the match is successful, a object;
+ /// otherwise, .
+ RouteMatch MatchUrlPath(string urlPath);
+
+ ///
+ /// Handles a request from a client.
+ ///
+ /// The context of the request being handled.
+ /// A representing the ongoing operation.
+ Task HandleRequestAsync(IHttpContext context);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IWebModuleContainer.cs b/src/EmbedIO/IWebModuleContainer.cs
new file mode 100644
index 000000000..7c951ecc7
--- /dev/null
+++ b/src/EmbedIO/IWebModuleContainer.cs
@@ -0,0 +1,19 @@
+using System;
+using Swan.Collections;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents an object that contains a collection of interfaces.
+ ///
+ public interface IWebModuleContainer : IDisposable
+ {
+ ///
+ /// Gets the modules.
+ ///
+ ///
+ /// The modules.
+ ///
+ IComponentCollection Modules { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/IWebServer.cs b/src/EmbedIO/IWebServer.cs
new file mode 100644
index 000000000..86a09b4cb
--- /dev/null
+++ b/src/EmbedIO/IWebServer.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Sessions;
+
+namespace EmbedIO
+{
+ ///
+ /// Represents a web server.
+ /// The basic usage of a web server is as follows:
+ ///
+ /// add modules to the Modules collection;
+ /// set a if needed;
+ /// call to respond to incoming requests.
+ ///
+ ///
+ public interface IWebServer : IWebModuleContainer, IMimeTypeCustomizer
+ {
+ ///
+ /// Occurs when the property changes.
+ ///
+ event WebServerStateChangedEventHandler StateChanged;
+
+ ///
+ /// Gets or sets a callback that is called every time an unhandled exception
+ /// occurs during the processing of a request.
+ /// This property can never be .
+ /// If it is still
+ ///
+ ///
+ ExceptionHandlerCallback OnUnhandledException { get; set; }
+
+ ///
+ /// Gets or sets a callback that is called every time a HTTP exception
+ /// is thrown during the processing of a request.
+ /// This property can never be .
+ ///
+ ///
+ HttpExceptionHandlerCallback OnHttpException { get; set; }
+
+ ///
+ /// Gets or sets the registered session ID manager, if any.
+ /// A session ID manager is an implementation of .
+ /// Note that this property can only be set before starting the web server.
+ ///
+ ///
+ /// The session manager, or if no session manager is present.
+ ///
+ /// This property is being set and the web server has already been started.
+ ISessionManager SessionManager { get; set; }
+
+ ///
+ /// Gets the state of the web server.
+ ///
+ /// The state.
+ ///
+ WebServerState State { get; }
+
+ ///
+ /// Starts the listener and the registered modules.
+ ///
+ /// The cancellation token; when cancelled, the server cancels all pending requests and stops.
+ ///
+ /// Returns the task that the HTTP listener is running inside of, so that it can be waited upon after it's been canceled.
+ ///
+ Task RunAsync(CancellationToken cancellationToken = default);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/BufferingResponseStream.cs b/src/EmbedIO/Internal/BufferingResponseStream.cs
new file mode 100644
index 000000000..3323c6abd
--- /dev/null
+++ b/src/EmbedIO/Internal/BufferingResponseStream.cs
@@ -0,0 +1,87 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EmbedIO.Internal
+{
+ // Wraps a response's output stream, buffering all data
+ // in a MemoryStream.
+ // When disposed, sets the response's ContentLength and copies all data
+ // to the output stream.
+ internal class BufferingResponseStream : Stream
+ {
+ private readonly IHttpResponse _response;
+ private readonly MemoryStream _buffer;
+
+ public BufferingResponseStream(IHttpResponse response)
+ {
+ _response = response;
+ _buffer = new MemoryStream();
+ }
+
+ public override bool CanRead => false;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => true;
+
+ public override long Length => _buffer.Length;
+
+ public override long Position
+ {
+ get => _buffer.Position;
+ set => throw SeekingNotSupported();
+ }
+
+ public override void Flush() => _buffer.Flush();
+
+ public override Task FlushAsync(CancellationToken cancellationToken) => _buffer.FlushAsync(cancellationToken);
+
+ public override int Read(byte[] buffer, int offset, int count) => throw ReadingNotSupported();
+
+ public override int ReadByte() => throw ReadingNotSupported();
+
+ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+ => throw ReadingNotSupported();
+
+ public override int EndRead(IAsyncResult asyncResult) => throw ReadingNotSupported();
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw ReadingNotSupported();
+
+ public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ => throw ReadingNotSupported();
+
+ public override long Seek(long offset, SeekOrigin origin) => throw SeekingNotSupported();
+
+ public override void SetLength(long value) => throw SeekingNotSupported();
+
+ public override void Write(byte[] buffer, int offset, int count) => _buffer.Write(buffer, offset, count);
+
+ public override void WriteByte(byte value) => _buffer.WriteByte(value);
+
+ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+ => _buffer.BeginWrite(buffer, offset, count, callback, state);
+
+ public override void EndWrite(IAsyncResult asyncResult) => _buffer.EndWrite(asyncResult);
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _buffer.WriteAsync(buffer, offset, count, cancellationToken);
+
+ protected override void Dispose(bool disposing)
+ {
+ _response.ContentLength64 = _buffer.Length;
+ _buffer.Position = 0;
+ _buffer.CopyTo(_response.OutputStream);
+
+ if (disposing)
+ {
+ _buffer.Dispose();
+ }
+ }
+
+ private static Exception ReadingNotSupported() => new NotSupportedException("This stream does not support reading.");
+
+ private static Exception SeekingNotSupported() => new NotSupportedException("This stream does not support seeking.");
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/CompressionStream.cs b/src/EmbedIO/Internal/CompressionStream.cs
new file mode 100644
index 000000000..2ec9f0224
--- /dev/null
+++ b/src/EmbedIO/Internal/CompressionStream.cs
@@ -0,0 +1,118 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EmbedIO.Internal
+{
+ internal class CompressionStream : Stream
+ {
+ private readonly Stream _target;
+ private readonly bool _leaveOpen;
+
+ public CompressionStream(Stream target, CompressionMethod compressionMethod)
+ {
+ switch (compressionMethod)
+ {
+ case CompressionMethod.Deflate:
+ _target = new DeflateStream(target, CompressionMode.Compress, true);
+ _leaveOpen = false;
+ break;
+ case CompressionMethod.Gzip:
+ _target = new GZipStream(target, CompressionMode.Compress, true);
+ _leaveOpen = false;
+ break;
+ default:
+ _target = target;
+ _leaveOpen = true;
+ break;
+ }
+
+ UncompressedLength = 0;
+ }
+
+ public long UncompressedLength { get; private set; }
+
+ public override bool CanRead => false;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => true;
+
+ public override void Flush() => _target.Flush();
+
+ public override Task FlushAsync(CancellationToken cancellationToken) => _target.FlushAsync(cancellationToken);
+
+ public override int Read(byte[] buffer, int offset, int count) => throw ReadingNotSupported();
+
+ public override int ReadByte() => throw ReadingNotSupported();
+
+ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+ => throw ReadingNotSupported();
+
+ public override int EndRead(IAsyncResult asyncResult) => throw ReadingNotSupported();
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw ReadingNotSupported();
+
+ public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ => throw ReadingNotSupported();
+
+ public override long Seek(long offset, SeekOrigin origin) => throw SeekingNotSupported();
+
+ public override void SetLength(long value) => throw SeekingNotSupported();
+
+ public override long Length => throw SeekingNotSupported();
+
+ public override long Position
+ {
+ get => throw SeekingNotSupported();
+ set => throw SeekingNotSupported();
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ _target.Write(buffer, offset, count);
+ UncompressedLength += count;
+ }
+
+ public override void WriteByte(byte value)
+ {
+ _target.WriteByte(value);
+ UncompressedLength++;
+ }
+
+ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+ {
+ return _target.BeginWrite(buffer, offset, count, ar => {
+ UncompressedLength += count;
+ callback(ar);
+ }, state);
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult)
+ {
+ _target.EndWrite(asyncResult);
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ await _target.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
+ UncompressedLength += count;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !_leaveOpen)
+ {
+ _target.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private static Exception ReadingNotSupported() => new NotSupportedException("This stream does not support reading.");
+
+ private static Exception SeekingNotSupported() => new NotSupportedException("This stream does not support seeking.");
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/CompressionUtility.cs b/src/EmbedIO/Internal/CompressionUtility.cs
new file mode 100644
index 000000000..47d11a3cf
--- /dev/null
+++ b/src/EmbedIO/Internal/CompressionUtility.cs
@@ -0,0 +1,82 @@
+using System.IO;
+using System.IO.Compression;
+
+namespace EmbedIO.Internal
+{
+ internal static class CompressionUtility
+ {
+ public static byte[] ConvertCompression(byte[] source, CompressionMethod sourceMethod, CompressionMethod targetMethod)
+ {
+ if (source == null)
+ return null;
+
+ if (sourceMethod == targetMethod)
+ return source;
+
+ switch (sourceMethod)
+ {
+ case CompressionMethod.Deflate:
+ using (var sourceStream = new MemoryStream(source, false))
+ using (var decompressionStream = new DeflateStream(sourceStream, CompressionMode.Decompress, true))
+ using (var targetStream = new MemoryStream())
+ {
+ if (targetMethod == CompressionMethod.Gzip)
+ {
+ using (var compressionStream = new GZipStream(targetStream, CompressionMode.Compress, true))
+ decompressionStream.CopyTo(compressionStream);
+ }
+ else
+ {
+ decompressionStream.CopyTo(targetStream);
+ }
+
+ return targetStream.ToArray();
+ }
+
+ case CompressionMethod.Gzip:
+ using (var sourceStream = new MemoryStream(source, false))
+ using (var decompressionStream = new GZipStream(sourceStream, CompressionMode.Decompress, true))
+ using (var targetStream = new MemoryStream())
+ {
+ if (targetMethod == CompressionMethod.Deflate)
+ {
+ using (var compressionStream = new DeflateStream(targetStream, CompressionMode.Compress, true))
+ decompressionStream.CopyToAsync(compressionStream);
+ }
+ else
+ {
+ decompressionStream.CopyTo(targetStream);
+ }
+
+ return targetStream.ToArray();
+ }
+
+ default:
+ using (var sourceStream = new MemoryStream(source, false))
+ using (var targetStream = new MemoryStream())
+ {
+ switch (targetMethod)
+ {
+ case CompressionMethod.Deflate:
+ using (var compressionStream = new DeflateStream(targetStream, CompressionMode.Compress, true))
+ sourceStream.CopyTo(compressionStream);
+
+ break;
+
+ case CompressionMethod.Gzip:
+ using (var compressionStream = new GZipStream(targetStream, CompressionMode.Compress, true))
+ sourceStream.CopyTo(compressionStream);
+
+ break;
+
+ default:
+ // Just in case. Consider all other values as None.
+ return source;
+ }
+
+ return targetStream.ToArray();
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/LockableNameValueCollection.cs b/src/EmbedIO/Internal/LockableNameValueCollection.cs
new file mode 100644
index 000000000..75853f773
--- /dev/null
+++ b/src/EmbedIO/Internal/LockableNameValueCollection.cs
@@ -0,0 +1,9 @@
+using System.Collections.Specialized;
+
+namespace EmbedIO.Internal
+{
+ internal sealed class LockableNameValueCollection : NameValueCollection
+ {
+ public void MakeReadOnly() => IsReadOnly = true;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/MimeTypeCustomizer.cs b/src/EmbedIO/Internal/MimeTypeCustomizer.cs
new file mode 100644
index 000000000..1d39224a7
--- /dev/null
+++ b/src/EmbedIO/Internal/MimeTypeCustomizer.cs
@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using EmbedIO.Utilities;
+using Swan.Configuration;
+
+namespace EmbedIO.Internal
+{
+ internal sealed class MimeTypeCustomizer : ConfiguredObject, IMimeTypeCustomizer
+ {
+ private readonly Dictionary _customMimeTypes = new Dictionary();
+ private readonly Dictionary<(string, string), bool> _data = new Dictionary<(string, string), bool>();
+
+ private bool? _defaultPreferCompression;
+
+ public string GetMimeType(string extension)
+ {
+ _customMimeTypes.TryGetValue(Validate.NotNull(nameof(extension), extension), out var result);
+ return result;
+ }
+
+ public bool TryDetermineCompression(string mimeType, out bool preferCompression)
+ {
+ var (type, subtype) = MimeType.UnsafeSplit(
+ Validate.MimeType(nameof(mimeType), mimeType, false));
+
+ if (_data.TryGetValue((type, subtype), out preferCompression))
+ return true;
+
+ if (_data.TryGetValue((type, "*"), out preferCompression))
+ return true;
+
+ if (!_defaultPreferCompression.HasValue)
+ return false;
+
+ preferCompression = _defaultPreferCompression.Value;
+ return true;
+ }
+
+ public void AddCustomMimeType(string extension, string mimeType)
+ {
+ EnsureConfigurationNotLocked();
+ _customMimeTypes[Validate.NotNullOrEmpty(nameof(extension), extension)]
+ = Validate.MimeType(nameof(mimeType), mimeType, false);
+ }
+
+ public void PreferCompression(string mimeType, bool preferCompression)
+ {
+ EnsureConfigurationNotLocked();
+ var (type, subtype) = MimeType.UnsafeSplit(
+ Validate.MimeType(nameof(mimeType), mimeType, true));
+
+ if (type == "*")
+ {
+ _defaultPreferCompression = preferCompression;
+ }
+ else
+ {
+ _data[(type, subtype)] = preferCompression;
+ }
+ }
+
+ public void Lock() => LockConfiguration();
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/RequestHandlerPassThroughException.cs b/src/EmbedIO/Internal/RequestHandlerPassThroughException.cs
new file mode 100644
index 000000000..c7dfac7c0
--- /dev/null
+++ b/src/EmbedIO/Internal/RequestHandlerPassThroughException.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace EmbedIO.Internal
+{
+ internal class RequestHandlerPassThroughException : Exception
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/SelfCheck.cs b/src/EmbedIO/Internal/SelfCheck.cs
new file mode 100644
index 000000000..6077b133f
--- /dev/null
+++ b/src/EmbedIO/Internal/SelfCheck.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace EmbedIO.Internal
+{
+ internal static class SelfCheck
+ {
+ public static void Fail(string message)
+ => throw new EmbedIOInternalErrorException(message);
+
+ public static void Fail(string message, Exception exception)
+ => throw new EmbedIOInternalErrorException(message, exception);
+
+ public static void Assert(bool condition, string message)
+ {
+ if (!condition)
+ throw new EmbedIOInternalErrorException(message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/TimeKeeper.cs b/src/EmbedIO/Internal/TimeKeeper.cs
new file mode 100644
index 000000000..03e90517e
--- /dev/null
+++ b/src/EmbedIO/Internal/TimeKeeper.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+
+namespace EmbedIO.Internal
+{
+ ///
+ /// Represents a wrapper around Stopwatch.
+ ///
+ public sealed class TimeKeeper
+ {
+ private static readonly Stopwatch Stopwatch = Stopwatch.StartNew();
+
+ private readonly long _start;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TimeKeeper()
+ {
+ _start = Stopwatch.ElapsedMilliseconds;
+ }
+
+ ///
+ /// Gets the elapsed time since the class was initialized.
+ ///
+ public long ElapsedTime => Stopwatch.ElapsedMilliseconds - _start;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/UriUtility.cs b/src/EmbedIO/Internal/UriUtility.cs
new file mode 100644
index 000000000..1f1aeeb56
--- /dev/null
+++ b/src/EmbedIO/Internal/UriUtility.cs
@@ -0,0 +1,65 @@
+using System;
+
+namespace EmbedIO.Internal
+{
+ internal static class UriUtility
+ {
+ // Returns true if string starts with "http:", "https:", "ws:", or "wss:"
+ public static bool CanBeAbsoluteUrl(string str)
+ {
+ if (string.IsNullOrEmpty(str))
+ return false;
+
+ switch (str[0])
+ {
+ case 'h':
+ if (str.Length < 5)
+ return false;
+ if (str[1] != 't' || str[2] != 't' || str[3] != 'p')
+ return false;
+ switch (str[4])
+ {
+ case ':':
+ return true;
+ case 's':
+ return str.Length >= 6 && str[5] == ':';
+ default:
+ return false;
+ }
+
+ case 'w':
+ if (str.Length < 3)
+ return false;
+ if (str[1] != 's')
+ return false;
+ switch (str[2])
+ {
+ case ':':
+ return true;
+ case 's':
+ return str.Length >= 4 && str[3] == ':';
+ default:
+ return false;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ public static Uri StringToUri(string str)
+ {
+ Uri.TryCreate(str, CanBeAbsoluteUrl(str) ? UriKind.Absolute : UriKind.Relative, out var result);
+ return result;
+ }
+
+ public static Uri StringToAbsoluteUri(string str)
+ {
+ if (!CanBeAbsoluteUrl(str))
+ return null;
+
+ Uri.TryCreate(str, UriKind.Absolute, out var result);
+ return result;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Internal/WebModuleCollection.cs b/src/EmbedIO/Internal/WebModuleCollection.cs
new file mode 100644
index 000000000..700fa2545
--- /dev/null
+++ b/src/EmbedIO/Internal/WebModuleCollection.cs
@@ -0,0 +1,46 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Swan.Collections;
+using Swan.Logging;
+
+namespace EmbedIO.Internal
+{
+ internal sealed class WebModuleCollection : DisposableComponentCollection
+ {
+ private readonly string _logSource;
+
+ internal WebModuleCollection(string logSource)
+ {
+ _logSource = logSource;
+ }
+
+ internal void StartAll(CancellationToken cancellationToken)
+ {
+ foreach (var (name, module) in WithSafeNames)
+ {
+ $"Starting module {name}...".Debug(_logSource);
+ module.Start(cancellationToken);
+ }
+ }
+
+ internal async Task DispatchRequestAsync(IHttpContext context)
+ {
+ if (context.IsHandled)
+ return;
+
+ var requestedPath = context.RequestedPath;
+ foreach (var (name, module) in WithSafeNames)
+ {
+ var routeMatch = module.MatchUrlPath(requestedPath);
+ if (routeMatch == null)
+ continue;
+
+ $"[{context.Id}] Processing with {name}.".Debug(_logSource);
+ (context as IHttpContextImpl).Route = routeMatch;
+ await module.HandleRequestAsync(context).ConfigureAwait(false);
+ if (context.IsHandled)
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/Constants/MimeTypes.cs b/src/EmbedIO/MimeType.Associations.cs
similarity index 96%
rename from src/Unosquare.Labs.EmbedIO/Constants/MimeTypes.cs
rename to src/EmbedIO/MimeType.Associations.cs
index 7e051ef84..b78133f0f 100644
--- a/src/Unosquare.Labs.EmbedIO/Constants/MimeTypes.cs
+++ b/src/EmbedIO/MimeType.Associations.cs
@@ -1,28 +1,10 @@
-namespace Unosquare.Labs.EmbedIO.Constants
+using System;
+using System.Collections.Generic;
+
+namespace EmbedIO
{
- using System;
- using System.Collections.Generic;
-
- ///
- /// Provides constants for commonly-used MIME types and association between file extensions and MIME types.
- ///
- public static class MimeTypes
+ partial class MimeType
{
- ///
- /// The MIME type for HTML.
- ///
- public const string HtmlType = "text/html";
-
- ///
- /// The MIME type for JSON.
- ///
- public const string JsonType = "application/json";
-
- ///
- /// The MIME type for URL-encoded HTML form contents.
- ///
- internal const string UrlEncodedContentType = "application/x-www-form-urlencoded";
-
// -------------------------------------------------------------------------------------------------
//
// IMPORTANT NOTE TO CONTRIBUTORS
@@ -30,9 +12,9 @@ public static class MimeTypes
//
// When you update the MIME type list, remember to:
//
- // * update the date in XML docs;
+ // * update the date in XML docs below;
//
- // * check the LICENSE file to see if copyright year and/or license condition have changed;
+ // * check the LICENSE file to see if copyright year and/or license conditions have changed;
//
// * if the URL for the LICENSE file has changed, update EmbedIO's LICENSE file too.
//
@@ -47,8 +29,7 @@ public static class MimeTypes
/// on April 26th, 2019.
/// Copyright (c) 2014 Samuel Neff. Redistributed under MIT license .
///
- [Obsolete("This method will be renamed as Associates")]
- public static IDictionary DefaultMimeTypes { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ public static IReadOnlyDictionary Associations { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
{".323", "text/h323"},
{".3g2", "video/3gpp2"},
diff --git a/src/EmbedIO/MimeType.cs b/src/EmbedIO/MimeType.cs
new file mode 100644
index 000000000..071407c39
--- /dev/null
+++ b/src/EmbedIO/MimeType.cs
@@ -0,0 +1,174 @@
+using System;
+using EmbedIO.Utilities;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides constants for commonly-used MIME types and association between file extensions and MIME types.
+ ///
+ ///
+ public static partial class MimeType
+ {
+ ///
+ /// The default MIME type for data whose type is unknown,
+ /// i.e. application/octet-stream .
+ ///
+ public const string Default = "application/octet-stream";
+
+ ///
+ /// The MIME type for plain text, i.e. text/plain .
+ ///
+ public const string PlainText = "text/plain";
+
+ ///
+ /// The MIME type for HTML, i.e. text/html .
+ ///
+ public const string Html = "text/html";
+
+ ///
+ /// The MIME type for JSON, i.e. application/json .
+ ///
+ public const string Json = "application/json";
+
+ ///
+ /// The MIME type for URL-encoded HTML forms,
+ /// i.e. application/x-www-form-urlencoded .
+ ///
+ internal const string UrlEncodedForm = "application/x-www-form-urlencoded";
+
+ ///
+ /// Strips parameters, if present (e.g. ; encoding=UTF-8 ), from a MIME type.
+ ///
+ /// The MIME type.
+ /// without parameters.
+ ///
+ /// This method does not validate : if it is not
+ /// a valid MIME type or media range, it is just returned unchanged.
+ ///
+ public static string StripParameters(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return value;
+
+ var semicolonPos = value.IndexOf(';');
+ return semicolonPos < 0
+ ? value
+ : value.Substring(0, semicolonPos).TrimEnd();
+ }
+
+ ///
+ /// Determines whether the specified string is a valid MIME type or media range.
+ ///
+ /// The value.
+ /// If set to , both media ranges
+ /// (e.g. "text/*" , "*/*" ) and specific MIME types (e.g. "text/html" )
+ /// are considered valid; if set to , only specific MIME types
+ /// are considered valid.
+ /// if is valid,
+ /// according to the value of ;
+ /// otherwise, .
+ public static bool IsMimeType(string value, bool acceptMediaRange)
+ {
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ var slashPos = value.IndexOf('/');
+ if (slashPos < 0)
+ return false;
+
+ var isWildcardSubtype = false;
+ var subtype = value.Substring(slashPos + 1);
+ if (subtype == "*")
+ {
+ if (!acceptMediaRange)
+ return false;
+
+ isWildcardSubtype = true;
+ }
+ else if (!Validate.IsRfc2616Token(subtype))
+ {
+ return false;
+ }
+
+ var type = value.Substring(0, slashPos);
+ return type == "*"
+ ? acceptMediaRange && isWildcardSubtype
+ : Validate.IsRfc2616Token(type);
+ }
+
+ ///
+ /// Splits the specified MIME type or media range into type and subtype.
+ ///
+ /// The MIME type or media range to split.
+ /// A tuple of type and subtype.
+ /// is .
+ /// is not a valid
+ /// MIME type or media range.
+ public static (string type, string subtype) Split(string mimeType)
+ => UnsafeSplit(Validate.MimeType(nameof(mimeType), mimeType, true));
+
+ ///
+ /// Matches the specified MIME type to a media range.
+ ///
+ /// The MIME type to match.
+ /// The media range.
+ /// if is either
+ /// the same as , or has the same type and a subtype
+ /// of "*" , or is "*/*" .
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ ///
+ /// is not a valid MIME type.
+ /// - or -
+ /// is not a valid MIME media range.
+ ///
+ public static bool IsInRange(string mimeType, string mediaRange)
+ => UnsafeIsInRange(
+ Validate.MimeType(nameof(mimeType), mimeType, false),
+ Validate.MimeType(nameof(mediaRange), mediaRange, true));
+
+ internal static (string type, string subtype) UnsafeSplit(string mimeType)
+ {
+ var slashPos = mimeType.IndexOf('/');
+ return (mimeType.Substring(0, slashPos), mimeType.Substring(slashPos + 1));
+ }
+
+ internal static bool UnsafeIsInRange(string mimeType, string mediaRange)
+ {
+ // A validated media range that starts with '*' can only be '*/*'
+ if (mediaRange[0] == '*')
+ return true;
+
+ var typeSlashPos = mimeType.IndexOf('/');
+ var rangeSlashPos = mediaRange.IndexOf('/');
+
+ if (typeSlashPos != rangeSlashPos)
+ return false;
+
+ for (var i = 0; i < typeSlashPos; i++)
+ {
+ if (mimeType[i] != mediaRange[i])
+ return false;
+ }
+
+ // A validated token has at least 1 character,
+ // thus there must be at least 1 character after a slash.
+ if (mediaRange[rangeSlashPos + 1] == '*')
+ return true;
+
+ if (mimeType.Length != mediaRange.Length)
+ return false;
+
+ for (var i = typeSlashPos + 1; i < mimeType.Length; i++)
+ {
+ if (mimeType[i] != mediaRange[i])
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/MimeTypeCustomizerExtensions.cs b/src/EmbedIO/MimeTypeCustomizerExtensions.cs
new file mode 100644
index 000000000..1c8121d20
--- /dev/null
+++ b/src/EmbedIO/MimeTypeCustomizerExtensions.cs
@@ -0,0 +1,99 @@
+using System;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides extension methods for types implementing .
+ ///
+ public static class MimeTypeCustomizerExtensions
+ {
+ ///
+ /// Adds a custom association between a file extension and a MIME type.
+ ///
+ /// The type of the object to which this method is applied.
+ /// The object to which this method is applied.
+ /// The file extension to associate to .
+ /// The MIME type to associate to .
+ /// with the custom association added.
+ /// is .
+ /// has its configuration locked.
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ ///
+ /// is the empty string.
+ /// - or -
+ /// is not a valid MIME type.
+ ///
+ public static T WithCustomMimeType(this T @this, string extension, string mimeType)
+ where T : IMimeTypeCustomizer
+ {
+ @this.AddCustomMimeType(extension, mimeType);
+ return @this;
+ }
+
+ ///
+ /// Indicates whether to prefer compression when negotiating content encoding
+ /// for a response with the specified content type, or whose content type is in
+ /// the specified media range.
+ ///
+ /// The type of the object to which this method is applied.
+ /// The object to which this method is applied.
+ /// The MIME type or media range.
+ /// to prefer compression;
+ /// otherwise, .
+ /// with the specified preference added.
+ /// is .
+ /// has its configuration locked.
+ /// is .
+ /// is not a valid MIME type or media range.
+ public static T PreferCompressionFor(this T @this, string mimeType, bool preferCompression)
+ where T : IMimeTypeCustomizer
+ {
+ @this.PreferCompression(mimeType, preferCompression);
+ return @this;
+ }
+
+ ///
+ /// Indicates that compression should be preferred when negotiating content encoding
+ /// for a response with the specified content type, or whose content type is in
+ /// the specified media range.
+ ///
+ /// The type of the object to which this method is applied.
+ /// The object to which this method is applied.
+ /// The MIME type or media range.
+ /// with the specified preference added.
+ /// is .
+ /// has its configuration locked.
+ /// is .
+ /// is not a valid MIME type or media range.
+ public static T PreferCompressionFor(this T @this, string mimeType)
+ where T : IMimeTypeCustomizer
+ {
+ @this.PreferCompression(mimeType, true);
+ return @this;
+ }
+
+ ///
+ /// Indicates that no compression should be preferred when negotiating content encoding
+ /// for a response with the specified content type, or whose content type is in
+ /// the specified media range.
+ ///
+ /// The type of the object to which this method is applied.
+ /// The object to which this method is applied.
+ /// The MIME type or media range.
+ /// with the specified preference added.
+ /// is .
+ /// has its configuration locked.
+ /// is .
+ /// is not a valid MIME type or media range.
+ public static T PreferNoCompressionFor(this T @this, string mimeType)
+ where T : IMimeTypeCustomizer
+ {
+ @this.PreferCompression(mimeType, false);
+ return @this;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ModuleGroup.cs b/src/EmbedIO/ModuleGroup.cs
new file mode 100644
index 000000000..9f97e102b
--- /dev/null
+++ b/src/EmbedIO/ModuleGroup.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using Swan.Collections;
+
+namespace EmbedIO
+{
+ ///
+ /// Groups modules under a common base URL path.
+ /// The BaseRoute property
+ /// of modules contained in a ModuleGroup is relative to the
+ /// ModuleGroup 's BaseRoute property.
+ /// For example, given the following code:
+ /// new ModuleGroup("/download")
+ /// .WithStaticFilesAt("/docs", "/var/my/documents");
+ /// files contained in the /var/my/documents folder will be
+ /// available to clients under the /download/docs/ URL.
+ ///
+ ///
+ ///
+ ///
+ public class ModuleGroup : WebModuleBase, IWebModuleContainer, IMimeTypeCustomizer
+ {
+ private readonly WebModuleCollection _modules;
+ private readonly MimeTypeCustomizer _mimeTypeCustomizer = new MimeTypeCustomizer();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The base route served by this module.
+ /// The value to set the property to.
+ /// See the help for the property for more information.
+ ///
+ ///
+ public ModuleGroup(string baseRoute, bool isFinalHandler)
+ : base(baseRoute)
+ {
+ IsFinalHandler = isFinalHandler;
+ _modules = new WebModuleCollection(nameof(ModuleGroup));
+ }
+
+ ///
+ /// Finalizes an instance of the class.
+ ///
+ ~ModuleGroup()
+ {
+ Dispose(false);
+ }
+
+ ///
+ public sealed override bool IsFinalHandler { get; }
+
+ ///
+ public IComponentCollection Modules => _modules;
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ string IMimeTypeProvider.GetMimeType(string extension)
+ => _mimeTypeCustomizer.GetMimeType(extension);
+
+ bool IMimeTypeProvider.TryDetermineCompression(string mimeType, out bool preferCompression)
+ => _mimeTypeCustomizer.TryDetermineCompression(mimeType, out preferCompression);
+
+ ///
+ public void AddCustomMimeType(string extension, string mimeType)
+ => _mimeTypeCustomizer.AddCustomMimeType(extension, mimeType);
+
+ ///
+ public void PreferCompression(string mimeType, bool preferCompression)
+ => _mimeTypeCustomizer.PreferCompression(mimeType, preferCompression);
+
+ ///
+ protected override Task OnRequestAsync(IHttpContext context)
+ => _modules.DispatchRequestAsync(context);
+
+ ///
+ /// Releases unmanaged and - optionally - managed resources.
+ ///
+ /// to release both managed and unmanaged resources;
+ /// to release only unmanaged resources.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing) return;
+
+ _modules.Dispose();
+ }
+
+ ///
+ protected override void OnBeforeLockConfiguration()
+ {
+ base.OnBeforeLockConfiguration();
+
+ _mimeTypeCustomizer.Lock();
+ }
+
+ ///
+ protected override void OnStart(CancellationToken cancellationToken)
+ {
+ _modules.StartAll(cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/CookieCollection.cs b/src/EmbedIO/Net/CookieList.cs
similarity index 78%
rename from src/Unosquare.Labs.EmbedIO/System.Net/CookieCollection.cs
rename to src/EmbedIO/Net/CookieList.cs
index 38604cd4e..769905175 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/CookieCollection.cs
+++ b/src/EmbedIO/Net/CookieList.cs
@@ -1,26 +1,28 @@
-namespace Unosquare.Net
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Text;
+using EmbedIO.Internal;
+using EmbedIO.Net.Internal;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Net
{
- using System;
- using System.Collections;
- using System.Collections.Generic;
- using System.Globalization;
- using System.Linq;
- using System.Net;
- using System.Text;
- using Labs.EmbedIO;
-
///
- /// Represents Cookie collection.
+ /// Provides a collection container for instances of .
+ /// This class is meant to be used internally by EmbedIO; you don't need to
+ /// use this class directly.
///
- public class CookieCollection
- : List, ICookieCollection
+#pragma warning disable CA1710 // Rename class to end in 'Collection' - it ends in 'List', i.e. 'Indexed Collection'.
+ public sealed class CookieList : List, ICookieCollection
+#pragma warning restore CA1710
{
///
public bool IsSynchronized => false;
- ///
- public object SyncRoot => ((ICollection) this).SyncRoot;
-
///
public Cookie this[string name]
{
@@ -40,66 +42,17 @@ public Cookie this[string name]
}
}
- ///
- public new void Add(Cookie cookie)
- {
- if (cookie == null)
- throw new ArgumentNullException(nameof(cookie));
-
- var pos = SearchCookie(cookie);
- if (pos == -1)
- {
- base.Add(cookie);
- return;
- }
-
- this[pos] = cookie;
- }
-
- ///
- public void CopyTo(Array array, int index)
+ /// Creates a by parsing
+ /// the value of one or more Cookie or Set-Cookie headers.
+ /// The value, or comma-separated list of values,
+ /// of the header or headers.
+ /// A newly-created instance of .
+ public static CookieList Parse(string headerValue)
{
- if (array == null)
- throw new ArgumentNullException(nameof(array));
-
- if (index < 0)
- throw new ArgumentOutOfRangeException(nameof(index), "Less than zero.");
-
- if (array.Rank > 1)
- throw new ArgumentException("Multidimensional.", nameof(array));
-
- if (array.Length - index < Count)
- {
- throw new ArgumentException(
- "The number of elements in this collection is greater than the available space of the destination array.");
- }
-
- if (array.GetType().GetElementType()?.IsAssignableFrom(typeof(Cookie)) != true)
- {
- throw new InvalidCastException(
- "The elements in this collection cannot be cast automatically to the type of the destination array.");
- }
-
- ((IList) this).CopyTo(array, index);
- }
-
- internal static string GetValue(string nameAndValue, bool unquote = false)
- {
- var idx = nameAndValue.IndexOf('=');
-
- if (idx < 0 || idx == nameAndValue.Length - 1)
- return null;
-
- var val = nameAndValue.Substring(idx + 1).Trim();
- return unquote ? val.Unquote() : val;
- }
-
- internal static CookieCollection ParseResponse(string value)
- {
- var cookies = new CookieCollection();
+ var cookies = new CookieList();
Cookie cookie = null;
- var pairs = SplitCookieHeaderValue(value);
+ var pairs = SplitCookieHeaderValue(headerValue);
for (var i = 0; i < pairs.Length; i++)
{
@@ -109,28 +62,23 @@ internal static CookieCollection ParseResponse(string value)
if (pair.StartsWith("version", StringComparison.OrdinalIgnoreCase) && cookie != null)
{
- cookie.Version = int.Parse(GetValue(pair, true));
+ cookie.Version = int.Parse(GetValue(pair, true), CultureInfo.InvariantCulture);
}
else if (pair.StartsWith("expires", StringComparison.OrdinalIgnoreCase) && cookie != null)
{
var buff = new StringBuilder(GetValue(pair), 32);
if (i < pairs.Length - 1)
- buff.AppendFormat(", {0}", pairs[++i].Trim());
+ buff.AppendFormat(CultureInfo.InvariantCulture, ", {0}", pairs[++i].Trim());
- if (!DateTime.TryParseExact(
- buff.ToString(),
- new[] {"ddd, dd'-'MMM'-'yyyy HH':'mm':'ss 'GMT'", "r"},
- new CultureInfo("en-US"),
- DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
- out var expires))
- expires = DateTime.Now;
+ if (!HttpDate.TryParse(buff.ToString(), out var expires))
+ expires = DateTimeOffset.Now;
if (cookie.Expires == DateTime.MinValue)
- cookie.Expires = expires.ToLocalTime();
+ cookie.Expires = expires.LocalDateTime;
}
else if (pair.StartsWith("max-age", StringComparison.OrdinalIgnoreCase) && cookie != null)
{
- var max = int.Parse(GetValue(pair, true));
+ var max = int.Parse(GetValue(pair, true), CultureInfo.InvariantCulture);
cookie.Expires = DateTime.Now.AddSeconds(max);
}
@@ -154,7 +102,7 @@ internal static CookieCollection ParseResponse(string value)
}
else if (pair.StartsWith("commenturl", StringComparison.OrdinalIgnoreCase) && cookie != null)
{
- cookie.CommentUri = GetValue(pair, true).ToUri();
+ cookie.CommentUri = UriUtility.StringToUri(GetValue(pair, true));
}
else if (pair.StartsWith("discard", StringComparison.OrdinalIgnoreCase) && cookie != null)
{
@@ -183,8 +131,61 @@ internal static CookieCollection ParseResponse(string value)
return cookies;
}
- private static string[] SplitCookieHeaderValue(string value)
- => new List(value.SplitHeaderValue(Labs.EmbedIO.Constants.Strings.CookieSplitChars)).ToArray();
+ ///
+ public new void Add(Cookie cookie)
+ {
+ if (cookie == null)
+ throw new ArgumentNullException(nameof(cookie));
+
+ var pos = SearchCookie(cookie);
+ if (pos == -1)
+ {
+ base.Add(cookie);
+ return;
+ }
+
+ this[pos] = cookie;
+ }
+
+ ///
+ public void CopyTo(Array array, int index)
+ {
+ if (array == null)
+ throw new ArgumentNullException(nameof(array));
+
+ if (index < 0)
+ throw new ArgumentOutOfRangeException(nameof(index), "Less than zero.");
+
+ if (array.Rank > 1)
+ throw new ArgumentException("Multidimensional.", nameof(array));
+
+ if (array.Length - index < Count)
+ {
+ throw new ArgumentException(
+ "The number of elements in this collection is greater than the available space of the destination array.");
+ }
+
+ if (array.GetType().GetElementType()?.IsAssignableFrom(typeof(Cookie)) != true)
+ {
+ throw new InvalidCastException(
+ "The elements in this collection cannot be cast automatically to the type of the destination array.");
+ }
+
+ ((IList) this).CopyTo(array, index);
+ }
+
+ private static string GetValue(string nameAndValue, bool unquote = false)
+ {
+ var idx = nameAndValue.IndexOf('=');
+
+ if (idx < 0 || idx == nameAndValue.Length - 1)
+ return null;
+
+ var val = nameAndValue.Substring(idx + 1).Trim();
+ return unquote ? val.Unquote() : val;
+ }
+
+ private static string[] SplitCookieHeaderValue(string value) => value.SplitHeaderValue(true).ToArray();
private static int CompareCookieWithinSorted(Cookie x, Cookie y)
{
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/EndPointManager.cs b/src/EmbedIO/Net/EndPointManager.cs
similarity index 72%
rename from src/Unosquare.Labs.EmbedIO/System.Net/EndPointManager.cs
rename to src/EmbedIO/Net/EndPointManager.cs
index 8e6358671..478738df0 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/EndPointManager.cs
+++ b/src/EmbedIO/Net/EndPointManager.cs
@@ -1,11 +1,11 @@
-namespace Unosquare.Net
-{
- using System.Threading.Tasks;
- using System.Collections.Generic;
- using System.Collections.Concurrent;
- using System.Net;
- using System.Net.Sockets;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using EmbedIO.Net.Internal;
+namespace EmbedIO.Net
+{
///
/// Represents the EndPoint Manager.
///
@@ -22,7 +22,7 @@ public static class EndPointManager
///
public static bool UseIpv6 { get; set; }
- internal static async Task AddListener(HttpListener listener)
+ internal static void AddListener(HttpListener listener)
{
var added = new List();
@@ -30,7 +30,7 @@ internal static async Task AddListener(HttpListener listener)
{
foreach (var prefix in listener.Prefixes)
{
- await AddPrefix(prefix, listener).ConfigureAwait(false);
+ AddPrefix(prefix, listener);
added.Add(prefix);
}
}
@@ -38,7 +38,7 @@ internal static async Task AddListener(HttpListener listener)
{
foreach (var prefix in added)
{
- await RemovePrefix(prefix, listener).ConfigureAwait(false);
+ RemovePrefix(prefix, listener);
}
throw;
@@ -58,15 +58,15 @@ internal static void RemoveEndPoint(EndPointListener epl, IPEndPoint ep)
epl.Close();
}
- internal static async Task RemoveListener(HttpListener listener)
+ internal static void RemoveListener(HttpListener listener)
{
foreach (var prefix in listener.Prefixes)
{
- await RemovePrefix(prefix, listener).ConfigureAwait(false);
+ RemovePrefix(prefix, listener);
}
}
- internal static async Task AddPrefix(string p, HttpListener listener)
+ internal static void AddPrefix(string p, HttpListener listener)
{
var lp = new ListenerPrefix(p);
@@ -74,11 +74,11 @@ internal static async Task AddPrefix(string p, HttpListener listener)
throw new HttpListenerException(400, "Invalid path.");
// listens on all the interfaces if host name cannot be parsed by IPAddress.
- var epl = await GetEpListener(lp.Host, lp.Port, listener, lp.Secure).ConfigureAwait(false);
+ var epl = GetEpListener(lp.Host, lp.Port, listener, lp.Secure);
epl.AddPrefix(lp, listener);
}
- private static async Task GetEpListener(string host, int port, HttpListener listener, bool secure = false)
+ private static EndPointListener GetEpListener(string host, int port, HttpListener listener, bool secure = false)
{
IPAddress address;
@@ -93,7 +93,7 @@ private static async Task GetEpListener(string host, int port,
var hostEntry = new IPHostEntry
{
HostName = host,
- AddressList = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false),
+ AddressList = Dns.GetHostAddresses(host),
};
address = hostEntry.AddressList[0];
@@ -110,7 +110,7 @@ private static async Task GetEpListener(string host, int port,
return epl;
}
- private static async Task RemovePrefix(string prefix, HttpListener listener)
+ private static void RemovePrefix(string prefix, HttpListener listener)
{
try
{
@@ -119,7 +119,7 @@ private static async Task RemovePrefix(string prefix, HttpListener listener)
if (!lp.IsValid())
return;
- var epl = await GetEpListener(lp.Host, lp.Port, listener, lp.Secure).ConfigureAwait(false);
+ var epl = GetEpListener(lp.Host, lp.Port, listener, lp.Secure);
epl.RemovePrefix(lp, listener);
}
catch (SocketException)
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListener.cs b/src/EmbedIO/Net/HttpListener.cs
similarity index 79%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpListener.cs
rename to src/EmbedIO/Net/HttpListener.cs
index 7d6550474..4089114ff 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListener.cs
+++ b/src/EmbedIO/Net/HttpListener.cs
@@ -1,24 +1,24 @@
-namespace Unosquare.Net
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Net.Internal;
+
+namespace EmbedIO.Net
{
- using System;
- using System.Security.Cryptography.X509Certificates;
- using System.Collections.Concurrent;
- using System.Collections.Generic;
- using System.Linq;
- using System.Threading;
- using System.Threading.Tasks;
- using Labs.EmbedIO;
-
///
/// The EmbedIO implementation of the standard HTTP Listener class.
///
/// Based on MONO HttpListener class.
///
///
- internal sealed class HttpListener : IHttpListener
+ public sealed class HttpListener : IHttpListener
{
private readonly SemaphoreSlim _ctxQueueSem = new SemaphoreSlim(0);
- private readonly ConcurrentDictionary _ctxQueue;
+ private readonly ConcurrentDictionary _ctxQueue;
private readonly ConcurrentDictionary _connections;
private readonly HttpListenerPrefixCollection _prefixes;
private bool _disposed;
@@ -33,7 +33,7 @@ public HttpListener(X509Certificate certificate =null)
_prefixes = new HttpListenerPrefixCollection(this);
_connections = new ConcurrentDictionary();
- _ctxQueue = new ConcurrentDictionary();
+ _ctxQueue = new ConcurrentDictionary();
}
///
@@ -42,6 +42,12 @@ public HttpListener(X509Certificate certificate =null)
///
public bool IsListening { get; private set; }
+ ///
+ public string Name { get; } = "Unosquare HTTP Listener";
+
+ ///
+ public List Prefixes => _prefixes.ToList();
+
///
/// Gets the certificate.
///
@@ -50,19 +56,13 @@ public HttpListener(X509Certificate certificate =null)
///
internal X509Certificate Certificate { get; }
- ///
- public string Name { get; } = "Unosquare HTTP Listener";
-
- ///
- public List Prefixes => _prefixes.ToList();
-
///
public void Start()
{
if (IsListening)
return;
- EndPointManager.AddListener(this).GetAwaiter().GetResult();
+ EndPointManager.AddListener(this);
IsListening = true;
}
@@ -87,11 +87,11 @@ public void Dispose()
}
///
- public async Task GetContextAsync(CancellationToken ct)
+ public async Task GetContextAsync(CancellationToken cancellationToken)
{
while (true)
{
- await _ctxQueueSem.WaitAsync(ct).ConfigureAwait(false);
+ await _ctxQueueSem.WaitAsync(cancellationToken).ConfigureAwait(false);
foreach (var key in _ctxQueue.Keys)
{
@@ -121,7 +121,7 @@ internal void RegisterContext(HttpListenerContext context)
private void Close(bool closeExisting)
{
- EndPointManager.RemoveListener(this).GetAwaiter().GetResult();
+ EndPointManager.RemoveListener(this);
var keys = _connections.Keys;
var connections = new HttpConnection[keys.Count];
@@ -134,9 +134,9 @@ private void Close(bool closeExisting)
if (!closeExisting) return;
- while (_ctxQueue.IsEmpty == false)
+ while (!_ctxQueue.IsEmpty)
{
- foreach (var key in _ctxQueue.Keys.Select(x => x).ToList())
+ foreach (var key in _ctxQueue.Keys.ToArray())
{
if (_ctxQueue.TryGetValue(key, out var context))
context.Connection.Close(true);
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/EndPointListener.cs b/src/EmbedIO/Net/Internal/EndPointListener.cs
similarity index 96%
rename from src/Unosquare.Labs.EmbedIO/System.Net/EndPointListener.cs
rename to src/EmbedIO/Net/Internal/EndPointListener.cs
index e23987d98..8601d8b49 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/EndPointListener.cs
+++ b/src/EmbedIO/Net/Internal/EndPointListener.cs
@@ -1,12 +1,12 @@
-namespace Unosquare.Net
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+
+namespace EmbedIO.Net.Internal
{
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using System.Net;
- using System.Net.Sockets;
- using System.Threading;
-
internal sealed class EndPointListener
{
private readonly Dictionary _unregistered;
@@ -238,7 +238,7 @@ private static void ProcessAccept(SocketAsyncEventArgs args)
return;
}
- HttpConnection conn = null;
+ HttpConnection conn;
try
{
conn = new HttpConnection(accepted, epl, epl.Listener.Certificate);
@@ -258,7 +258,7 @@ private static void ProcessAccept(SocketAsyncEventArgs args)
private static void OnAccept(object sender, SocketAsyncEventArgs e) => ProcessAccept(e);
- private static HttpListener MatchFromList(string path, IReadOnlyCollection list, out ListenerPrefix prefix)
+ private static HttpListener MatchFromList(string path, List list, out ListenerPrefix prefix)
{
prefix = null;
if (list == null)
diff --git a/src/EmbedIO/Net/Internal/HttpConnection.InputState.cs b/src/EmbedIO/Net/Internal/HttpConnection.InputState.cs
new file mode 100644
index 000000000..57bc2c5e5
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/HttpConnection.InputState.cs
@@ -0,0 +1,11 @@
+namespace EmbedIO.Net.Internal
+{
+ partial class HttpConnection
+ {
+ private enum InputState
+ {
+ RequestLine,
+ Headers,
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Net/Internal/HttpConnection.LineState.cs b/src/EmbedIO/Net/Internal/HttpConnection.LineState.cs
new file mode 100644
index 000000000..4a3feca51
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/HttpConnection.LineState.cs
@@ -0,0 +1,12 @@
+namespace EmbedIO.Net.Internal
+{
+ partial class HttpConnection
+ {
+ private enum LineState
+ {
+ None,
+ Cr,
+ Lf,
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpConnection.cs b/src/EmbedIO/Net/Internal/HttpConnection.cs
similarity index 88%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpConnection.cs
rename to src/EmbedIO/Net/Internal/HttpConnection.cs
index 4a5a35a8a..2ca1e00cf 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpConnection.cs
+++ b/src/EmbedIO/Net/Internal/HttpConnection.cs
@@ -1,17 +1,16 @@
-namespace Unosquare.Net
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EmbedIO.Net.Internal
{
- using System;
- using System.Collections.Generic;
- using System.IO;
- using System.Net;
- using System.Net.Security;
- using System.Net.Sockets;
- using System.Security.Cryptography.X509Certificates;
- using System.Text;
- using System.Threading;
- using System.Threading.Tasks;
-
- internal sealed class HttpConnection : IDisposable
+ internal sealed partial class HttpConnection : IDisposable
{
internal const int BufferSize = 8192;
@@ -26,7 +25,6 @@ internal sealed class HttpConnection : IDisposable
private ResponseStream _oStream;
private bool _contextBound;
private int _sTimeout = 90000; // 90k ms for first request, 15k ms from then on
- private IPEndPoint _localEp;
private HttpListener _lastListener;
private InputState _inputState = InputState.RequestLine;
private LineState _lineState = LineState.None;
@@ -47,10 +45,9 @@ public HttpConnection(Socket sock, EndPointListener epl, X509Certificate cert)
else
{
var sslStream = new SslStream(new NetworkStream(sock, false), true);
-
try
{
- sslStream.AuthenticateAsServerAsync(cert).GetAwaiter().GetResult();
+ sslStream.AuthenticateAsServer(cert);
}
catch
{
@@ -65,6 +62,11 @@ public HttpConnection(Socket sock, EndPointListener epl, X509Certificate cert)
Init();
}
+ ~HttpConnection()
+ {
+ Dispose(false);
+ }
+
public int Reuses { get; private set; }
public Stream Stream { get; }
@@ -76,9 +78,15 @@ public HttpConnection(Socket sock, EndPointListener epl, X509Certificate cert)
public bool IsSecure { get; }
public ListenerPrefix Prefix { get; set; }
-
+
internal X509Certificate2 ClientCertificate { get; }
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
public async Task BeginReadRequest()
{
if (_buffer == null)
@@ -118,6 +126,8 @@ public ResponseStream GetResponseStream() => _oStream ??
(_oStream =
new ResponseStream(Stream, _context.HttpListenerResponse, _context.Listener?.IgnoreWriteExceptions ?? true));
+ internal void ForceClose() => Close(true);
+
internal void Close(bool forceClose = false)
{
if (_sock != null)
@@ -129,14 +139,13 @@ internal void Close(bool forceClose = false)
if (_sock == null) return;
- forceClose |= !_context.Request.KeepAlive;
-
- if (!forceClose)
- forceClose = _context.Response.Headers["connection"] == "close";
+ forceClose = forceClose
+ || !_context.Request.KeepAlive
+ || _context.Response.Headers["connection"] == "close";
if (!forceClose)
{
- if (_context.HttpListenerRequest.FlushInput().GetAwaiter().GetResult())
+ if (_context.HttpListenerRequest.FlushInput())
{
Reuses++;
Unbind();
@@ -148,20 +157,17 @@ internal void Close(bool forceClose = false)
}
}
- var s = _sock;
- _sock = null;
-
- try
- {
- s?.Shutdown(SocketShutdown.Both);
- }
- catch
- {
- // ignored
- }
- finally
+ using (var s = _sock)
{
- s?.Dispose();
+ _sock = null;
+ try
+ {
+ s?.Shutdown(SocketShutdown.Both);
+ }
+ catch
+ {
+ // ignored
+ }
}
Unbind();
@@ -323,7 +329,7 @@ private bool ProcessInput(MemoryStream ms)
return false;
}
- private string ReadLine(IReadOnlyList buffer, int offset, int len, out int used)
+ private string ReadLine(byte[] buffer, int offset, int len, out int used)
{
if (_currentLine == null)
_currentLine = new StringBuilder(128);
@@ -382,23 +388,13 @@ private void CloseSocket()
RemoveConnection();
}
- private enum InputState
- {
- RequestLine,
- Headers,
- }
-
- private enum LineState
- {
- None,
- Cr,
- Lf,
- }
-
- public void Dispose()
+ private void Dispose(bool disposing)
{
Close(true);
+ if (!disposing)
+ return;
+
_timer?.Dispose();
_sock?.Dispose();
_ms?.Dispose();
diff --git a/src/EmbedIO/Net/Internal/HttpListenerContext.cs b/src/EmbedIO/Net/Internal/HttpListenerContext.cs
new file mode 100644
index 000000000..ee37a2fde
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/HttpListenerContext.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Routing;
+using EmbedIO.Sessions;
+using EmbedIO.Utilities;
+using EmbedIO.WebSockets;
+using EmbedIO.WebSockets.Internal;
+using Swan.Logging;
+
+namespace EmbedIO.Net.Internal
+{
+ // Provides access to the request and response objects used by the HttpListener class.
+ internal sealed class HttpListenerContext : IHttpContextImpl
+ {
+ private readonly Lazy> _items =
+ new Lazy>(() => new Dictionary(), true);
+
+ private readonly TimeKeeper _ageKeeper = new TimeKeeper();
+
+ private readonly Stack> _closeCallbacks = new Stack>();
+
+ private bool _isHandled;
+ private bool _closed;
+
+ internal HttpListenerContext(HttpConnection cnc)
+ {
+ Connection = cnc;
+ Request = new HttpListenerRequest(this);
+ Response = new HttpListenerResponse(this);
+ User = null;
+ Id = UniqueIdGenerator.GetNext();
+ LocalEndPoint = Request.LocalEndPoint;
+ RemoteEndPoint = Request.RemoteEndPoint;
+ }
+
+ public string Id { get; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ public long Age => _ageKeeper.ElapsedTime;
+
+ public IPEndPoint LocalEndPoint { get; }
+
+ public IPEndPoint RemoteEndPoint { get; }
+
+ public IHttpRequest Request { get; }
+
+ public RouteMatch Route { get; set; }
+
+ public string RequestedPath => Route.SubPath;
+
+ public IHttpResponse Response { get; }
+
+ public IPrincipal User { get; }
+
+ public ISessionProxy Session { get; set; }
+
+ public bool SupportCompressedRequests { get; set; }
+
+ public IDictionary Items => _items.Value;
+
+ public bool IsHandled => _isHandled;
+
+ public MimeTypeProviderStack MimeTypeProviders { get; } = new MimeTypeProviderStack();
+
+ internal HttpListenerRequest HttpListenerRequest => Request as HttpListenerRequest;
+
+ internal HttpListenerResponse HttpListenerResponse => Response as HttpListenerResponse;
+
+ internal HttpListener Listener { get; set; }
+
+ internal string ErrorMessage { get; set; }
+
+ internal bool HaveError => ErrorMessage != null;
+
+ internal HttpConnection Connection { get; }
+
+ public void SetHandled() => _isHandled = true;
+
+ public void OnClose(Action callback)
+ {
+ if (_closed)
+ throw new InvalidOperationException("HTTP context has already been closed.");
+
+ _closeCallbacks.Push(Validate.NotNull(nameof(callback), callback));
+ }
+
+ public void Close()
+ {
+ _closed = true;
+
+ // Always close the response stream no matter what.
+ Response.Close();
+
+ foreach (var callback in _closeCallbacks)
+ {
+ try
+ {
+ callback(this);
+ }
+ catch (Exception e)
+ {
+ e.Log("HTTP context", $"[{Id}] Exception thrown by a HTTP context close callback.");
+ }
+ }
+ }
+
+ public async Task AcceptWebSocketAsync(
+ IEnumerable requestedProtocols,
+ string acceptedProtocol,
+ int receiveBufferSize,
+ TimeSpan keepAliveInterval,
+ CancellationToken cancellationToken)
+ {
+ var webSocket = await WebSocket.AcceptAsync(this, acceptedProtocol).ConfigureAwait(false);
+ return new WebSocketContext(this, WebSocket.SupportedVersion, requestedProtocols, acceptedProtocol, webSocket, cancellationToken);
+ }
+
+ public string GetMimeType(string extension)
+ => MimeTypeProviders.GetMimeType(extension);
+
+ public bool TryDetermineCompression(string mimeType, out bool preferCompression)
+ => MimeTypeProviders.TryDetermineCompression(mimeType, out preferCompression);
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerPrefixCollection.cs b/src/EmbedIO/Net/Internal/HttpListenerPrefixCollection.cs
similarity index 76%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerPrefixCollection.cs
rename to src/EmbedIO/Net/Internal/HttpListenerPrefixCollection.cs
index ae124c8cc..d89029109 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerPrefixCollection.cs
+++ b/src/EmbedIO/Net/Internal/HttpListenerPrefixCollection.cs
@@ -1,7 +1,7 @@
-namespace Unosquare.Net
-{
- using System.Collections.Generic;
+using System.Collections.Generic;
+namespace EmbedIO.Net.Internal
+{
internal class HttpListenerPrefixCollection : List
{
private readonly HttpListener _listener;
@@ -19,7 +19,7 @@ internal HttpListenerPrefixCollection(HttpListener listener)
base.Add(uriPrefix);
if (_listener.IsListening)
- EndPointManager.AddPrefix(uriPrefix, _listener).GetAwaiter().GetResult();
+ EndPointManager.AddPrefix(uriPrefix, _listener);
}
}
}
\ No newline at end of file
diff --git a/src/EmbedIO/Net/Internal/HttpListenerRequest.GccDelegate.cs b/src/EmbedIO/Net/Internal/HttpListenerRequest.GccDelegate.cs
new file mode 100644
index 000000000..307941663
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/HttpListenerRequest.GccDelegate.cs
@@ -0,0 +1,9 @@
+using System.Security.Cryptography.X509Certificates;
+
+namespace EmbedIO.Net.Internal
+{
+ partial class HttpListenerRequest
+ {
+ private delegate X509Certificate2 GccDelegate();
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerRequest.cs b/src/EmbedIO/Net/Internal/HttpListenerRequest.cs
similarity index 86%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerRequest.cs
rename to src/EmbedIO/Net/Internal/HttpListenerRequest.cs
index f0695270e..44bc32411 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerRequest.cs
+++ b/src/EmbedIO/Net/Internal/HttpListenerRequest.cs
@@ -1,20 +1,20 @@
-namespace Unosquare.Net
+using System;
+using System.Collections.Specialized;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Net.Internal
{
- using System;
- using System.Collections.Specialized;
- using System.IO;
- using System.Linq;
- using System.Net;
- using System.Text;
- using System.Threading.Tasks;
- using Labs.EmbedIO;
- using System.Security.Cryptography.X509Certificates;
-
///
/// Represents an HTTP Listener Request.
///
- internal sealed class HttpListenerRequest
- : IHttpRequest
+ internal sealed partial class HttpListenerRequest : IHttpRequest
{
private static readonly byte[] HttpStatus100 = Encoding.UTF8.GetBytes("HTTP/1.1 100 Continue\r\n\r\n");
private static readonly char[] Separators = { ' ' };
@@ -22,13 +22,12 @@ internal sealed class HttpListenerRequest
private readonly HttpListenerContext _context;
private Encoding _contentEncoding;
private bool _clSet;
- private CookieCollection _cookies;
+ private CookieList _cookies;
private Stream _inputStream;
private Uri _url;
private bool _kaSet;
private bool _keepAlive;
- private delegate X509Certificate2 GccDelegate();
private GccDelegate _gccDelegate;
internal HttpListenerRequest(HttpListenerContext context)
@@ -65,12 +64,12 @@ public Encoding ContentEncoding
}
var defaultEncoding = Encoding.UTF8;
- var acceptCharset = Headers["Accept-Charset"]?.Split(Labs.EmbedIO.Constants.Strings.CommaSplitChar)
+ var acceptCharset = Headers["Accept-Charset"]?.SplitByComma()
.Select(x => x.Trim().Split(';'))
.Select(x => new
{
Charset = x[0],
- Q = x.Length == 1 ? 1m : decimal.Parse(x[1].Trim().Replace("q=", string.Empty)),
+ Q = x.Length == 1 ? 1m : decimal.Parse(x[1].Trim().Replace("q=", string.Empty), CultureInfo.InvariantCulture),
})
.OrderBy(x => x.Q)
.Select(x => x.Charset)
@@ -92,7 +91,7 @@ public Encoding ContentEncoding
public string ContentType => Headers["content-type"];
///
- public ICookieCollection Cookies => _cookies ?? (_cookies = new CookieCollection());
+ public ICookieCollection Cookies => _cookies ?? (_cookies = new CookieList());
///
public bool HasEntityBody => ContentLength64 > 0;
@@ -103,6 +102,9 @@ public Encoding ContentEncoding
///
public string HttpMethod { get; private set; }
+ ///
+ public HttpVerbs HttpVerb { get; private set; }
+
///
public Stream InputStream => _inputStream ??
(_inputStream =
@@ -114,9 +116,7 @@ public Encoding ContentEncoding
///
public bool IsLocal => LocalEndPoint?.Address?.Equals(RemoteEndPoint?.Address) ?? true;
- ///
- /// Gets a value indicating whether this request is under a secure connection.
- ///
+ ///
public bool IsSecureConnection => _context.Connection.IsSecure;
///
@@ -177,9 +177,6 @@ public bool KeepAlive
///
public string UserAgent => Headers["user-agent"];
- ///
- public Guid RequestTraceIdentifier => Guid.NewGuid();
-
public string UserHostAddress => LocalEndPoint.ToString();
public string UserHostName => Headers["host"];
@@ -187,7 +184,48 @@ public bool KeepAlive
public string[] UserLanguages { get; private set; }
///
- public bool IsWebSocketRequest => HttpMethod == "GET" && ProtocolVersion > HttpVersion.Version10 && Headers.Contains("Upgrade", "websocket") && Headers.Contains("Connection", "Upgrade");
+ public bool IsWebSocketRequest
+ => HttpVerb == HttpVerbs.Get
+ && ProtocolVersion >= HttpVersion.Version11
+ && Headers.Contains("Upgrade", "websocket")
+ && Headers.Contains("Connection", "Upgrade");
+
+ ///
+ /// Begins to the get client certificate asynchronously.
+ ///
+ /// The request callback.
+ /// The state.
+ /// An async result.
+ public IAsyncResult BeginGetClientCertificate(AsyncCallback requestCallback, object state)
+ {
+ if (_gccDelegate == null)
+ _gccDelegate = GetClientCertificate;
+ return _gccDelegate.BeginInvoke(requestCallback, state);
+ }
+
+ ///
+ /// Finishes the get client certificate asynchronous operation.
+ ///
+ /// The asynchronous result.
+ /// The certificate from the client.
+ /// asyncResult.
+ ///
+ public X509Certificate2 EndGetClientCertificate(IAsyncResult asyncResult)
+ {
+ if (asyncResult == null)
+ throw new ArgumentNullException(nameof(asyncResult));
+
+ if (_gccDelegate == null)
+ throw new InvalidOperationException();
+
+ return _gccDelegate.EndInvoke(asyncResult);
+ }
+
+ ///
+ /// Gets the client certificate.
+ ///
+ /// The client certificate.
+ public X509Certificate2 GetClientCertificate() => _context.Connection.ClientCertificate;
internal void SetRequestLine(string req)
{
@@ -199,6 +237,8 @@ internal void SetRequestLine(string req)
}
HttpMethod = parts[0];
+ Enum.TryParse(HttpMethod, true, out var verb);
+ HttpVerb = verb;
foreach (var c in HttpMethod)
{
@@ -244,16 +284,11 @@ internal void FinishInitialization()
return;
}
- Uri rawUri = null;
- var path = RawUrl.ToLowerInvariant().MaybeUri() && Uri.TryCreate(RawUrl, UriKind.Absolute, out rawUri)
- ? rawUri.PathAndQuery
- : RawUrl;
+ var rawUri = UriUtility.StringToAbsoluteUri(RawUrl.ToLowerInvariant());
+ var path = rawUri?.PathAndQuery ?? RawUrl;
if (string.IsNullOrEmpty(host))
- host = UserHostAddress;
-
- if (rawUri != null)
- host = rawUri.Host;
+ host = rawUri?.Host ?? UserHostAddress;
var colon = host.LastIndexOf(':');
if (colon >= 0)
@@ -270,14 +305,8 @@ internal void FinishInitialization()
CreateQueryString(_url.Query);
- if (!_clSet)
- {
- if (string.Compare(HttpMethod, "POST", StringComparison.OrdinalIgnoreCase) == 0 ||
- string.Compare(HttpMethod, "PUT", StringComparison.OrdinalIgnoreCase) == 0)
- {
- return;
- }
- }
+ if (!_clSet && (HttpVerb == HttpVerbs.Post || HttpVerb == HttpVerbs.Put))
+ return;
if (string.Compare(Headers["Expect"], "100-continue", StringComparison.OrdinalIgnoreCase) == 0)
{
@@ -302,16 +331,16 @@ internal void AddHeader(string header)
switch (name.ToLowerInvariant())
{
case "accept-language":
- UserLanguages = val.Split(Labs.EmbedIO.Constants.Strings.CommaSplitChar); // yes, only split with a ','
+ UserLanguages = val.SplitByComma(); // yes, only split with a ','
break;
case "accept":
- AcceptTypes = val.Split(Labs.EmbedIO.Constants.Strings.CommaSplitChar); // yes, only split with a ','
+ AcceptTypes = val.SplitByComma(); // yes, only split with a ','
break;
case "content-length":
try
{
// TODO: max. content_length?
- ContentLength64 = long.Parse(val.Trim());
+ ContentLength64 = long.Parse(val.Trim(), CultureInfo.InvariantCulture);
if (ContentLength64 < 0)
_context.ErrorMessage = "Invalid Content-Length.";
_clSet = true;
@@ -341,7 +370,7 @@ internal void AddHeader(string header)
}
// returns true is the stream could be reused.
- internal async Task FlushInput()
+ internal bool FlushInput()
{
if (!HasEntityBody)
return true;
@@ -356,7 +385,7 @@ internal async Task FlushInput()
{
try
{
- var data = await InputStream.ReadAsync(bytes, 0, length).ConfigureAwait(false);
+ var data = InputStream.Read(bytes, 0, length);
if (data <= 0)
return true;
@@ -376,9 +405,9 @@ internal async Task FlushInput()
private void ParseCookies(string val)
{
if (_cookies == null)
- _cookies = new CookieCollection();
+ _cookies = new CookieList();
- var cookieStrings = val.Split(Labs.EmbedIO.Constants.Strings.CookieSplitChars)
+ var cookieStrings = val.SplitByAny(';', ',')
.Where(x => !string.IsNullOrEmpty(x));
Cookie current = null;
var version = 0;
@@ -387,7 +416,7 @@ private void ParseCookies(string val)
{
if (str.StartsWith("$Version"))
{
- version = int.Parse(str.Substring(str.IndexOf('=') + 1).Unquote());
+ version = int.Parse(str.Substring(str.IndexOf('=') + 1).Unquote(), CultureInfo.InvariantCulture);
}
else if (str.StartsWith("$Path") && current != null)
{
@@ -460,42 +489,5 @@ private void CreateQueryString(string query)
}
}
}
-
- ///
- /// Begins to the get client certificate asynchronously.
- ///
- /// The request callback.
- /// The state.
- /// An async result.
- public IAsyncResult BeginGetClientCertificate(AsyncCallback requestCallback, object state)
- {
- if (_gccDelegate == null)
- _gccDelegate = GetClientCertificate;
- return _gccDelegate.BeginInvoke(requestCallback, state);
- }
-
- ///
- /// Finishes the get client certificate asynchronous operation.
- ///
- /// The asynchronous result.
- /// The certificate from the client.
- /// asyncResult.
- ///
- public X509Certificate2 EndGetClientCertificate(IAsyncResult asyncResult)
- {
- if (asyncResult == null)
- throw new ArgumentNullException(nameof(asyncResult));
-
- if (_gccDelegate == null)
- throw new InvalidOperationException();
-
- return _gccDelegate.EndInvoke(asyncResult);
- }
-
- ///
- /// Gets the client certificate.
- ///
- ///
- public X509Certificate2 GetClientCertificate() => _context.Connection.ClientCertificate;
}
}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerResponse.cs b/src/EmbedIO/Net/Internal/HttpListenerResponse.cs
similarity index 75%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerResponse.cs
rename to src/EmbedIO/Net/Internal/HttpListenerResponse.cs
index 062b8b713..4f86f0afd 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerResponse.cs
+++ b/src/EmbedIO/Net/Internal/HttpListenerResponse.cs
@@ -1,20 +1,18 @@
-namespace Unosquare.Net
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Net.Internal
{
- using Labs.EmbedIO;
- using System;
- using System.Collections.Specialized;
- using System.Globalization;
- using System.IO;
- using System.Linq;
- using System.Net;
- using System.Text;
-
///
/// Represents an HTTP Listener's response.
///
///
- internal sealed class HttpListenerResponse
- : IHttpResponse, IDisposable
+ internal sealed class HttpListenerResponse : IHttpResponse, IDisposable
{
private const string CannotChangeHeaderWarning = "Cannot be changed after headers are sent.";
private readonly HttpListenerContext _context;
@@ -22,7 +20,7 @@ internal sealed class HttpListenerResponse
private long _contentLength;
private bool _clSet;
private string _contentType;
- private CookieCollection _cookies;
+ private CookieList _cookies;
private bool _keepAlive = true;
private ResponseStream _outputStream;
private int _statusCode = 200;
@@ -78,7 +76,7 @@ public string ContentType
public ICookieCollection Cookies => CookieCollection;
///
- public NameValueCollection Headers => HeaderCollection;
+ public WebHeaderCollection Headers { get; } = new WebHeaderCollection();
///
public bool KeepAlive
@@ -102,7 +100,7 @@ public bool KeepAlive
_outputStream ?? (_outputStream = _context.Connection.GetResponseStream());
///
- public Version ProtocolVersion { get; set; } = HttpVersion.Version11;
+ public Version ProtocolVersion { get; } = HttpVersion.Version11;
///
/// Gets or sets a value indicating whether [send chunked].
@@ -148,41 +146,25 @@ public int StatusCode
throw new ArgumentOutOfRangeException(nameof(StatusCode), "StatusCode must be between 100 and 999.");
_statusCode = value;
-
- if (HttpStatusDescription.TryGet(value, out var description))
- StatusDescription = description;
+ StatusDescription = HttpListenerResponseHelper.GetStatusDescription(value);
}
}
///
public string StatusDescription { get; set; } = "OK";
- internal CookieCollection CookieCollection
+ internal CookieList CookieCollection
{
- get => _cookies ?? (_cookies = new CookieCollection());
+ get => _cookies ?? (_cookies = new CookieList());
set => _cookies = value;
}
- internal WebHeaderCollection HeaderCollection { get; set; } = new WebHeaderCollection();
-
internal bool HeadersSent { get; private set; }
internal object HeadersLock { get; } = new object();
internal bool ForceCloseChunked { get; private set; }
void IDisposable.Dispose() => Close(true);
- ///
- public void AddHeader(string name, string value)
- {
- if (string.IsNullOrWhiteSpace(name))
- throw new ArgumentException("'name' cannot be empty", nameof(name));
-
- if (value.Length > 65535)
- throw new ArgumentOutOfRangeException(nameof(value));
-
- Headers[name] = value;
- }
-
public void Close()
{
if (!_disposed) Close(false);
@@ -202,7 +184,7 @@ public void SetCookie(Cookie cookie)
}
else
{
- _cookies = new CookieCollection();
+ _cookies = new CookieList();
}
_cookies.Add(cookie);
@@ -216,15 +198,15 @@ internal MemoryStream SendHeaders(bool closing)
? $"{_contentType}; charset={Encoding.UTF8.WebName}"
: _contentType;
- HeaderCollection.Add("Content-Type", contentTypeValue);
+ Headers.Add(HttpHeaderNames.ContentType, contentTypeValue);
}
- if (Headers["Server"] == null)
- HeaderCollection.Add("Server", HttpResponse.ServerVersion);
+ if (Headers[HttpHeaderNames.Server] == null)
+ Headers.Add(HttpHeaderNames.Server, HttpResponse.ServerVersion);
var inv = CultureInfo.InvariantCulture;
- if (Headers["Date"] == null)
- HeaderCollection.Add("Date", DateTime.UtcNow.ToString("r", inv));
+ if (Headers[HttpHeaderNames.Date] == null)
+ Headers.Add(HttpHeaderNames.Date, DateTime.UtcNow.ToString("r", inv));
if (!_chunked)
{
@@ -235,7 +217,7 @@ internal MemoryStream SendHeaders(bool closing)
}
if (_clSet)
- HeaderCollection.Add("Content-Length", _contentLength.ToString(inv));
+ Headers.Add(HttpHeaderNames.ContentLength, _contentLength.ToString(inv));
}
var v = _context.Request.ProtocolVersion;
@@ -243,10 +225,10 @@ internal MemoryStream SendHeaders(bool closing)
_chunked = true;
//// Apache forces closing the connection for these status codes:
- //// HttpStatusCode.BadRequest 400
+ //// HttpStatusCode.BadRequest 400
//// HttpStatusCode.RequestTimeout 408
//// HttpStatusCode.LengthRequired 411
- //// HttpStatusCode.RequestEntityTooLarge 413
+ //// HttpStatusCode.RequestEntityTooLarge 413
//// HttpStatusCode.RequestUriTooLong 414
//// HttpStatusCode.InternalServerError 500
//// HttpStatusCode.ServiceUnavailable 503
@@ -254,18 +236,17 @@ internal MemoryStream SendHeaders(bool closing)
_statusCode == 413 || _statusCode == 414 || _statusCode == 500 ||
_statusCode == 503;
- if (connClose == false)
- connClose = !_context.Request.KeepAlive;
+ connClose |= !_context.Request.KeepAlive;
// They sent both KeepAlive: true and Connection: close!?
if (!_keepAlive || connClose)
{
- HeaderCollection.Add("Connection", "close");
+ Headers.Add(HttpHeaderNames.Connection, "close");
connClose = true;
}
if (_chunked)
- HeaderCollection.Add("Transfer-Encoding", "chunked");
+ Headers.Add(HttpHeaderNames.TransferEncoding, "chunked");
var reuses = _context.Connection.Reuses;
if (reuses >= 100)
@@ -273,17 +254,17 @@ internal MemoryStream SendHeaders(bool closing)
ForceCloseChunked = true;
if (!connClose)
{
- HeaderCollection.Add("Connection", "close");
+ Headers.Add(HttpHeaderNames.Connection, "close");
connClose = true;
}
}
if (!connClose)
{
- HeaderCollection.Add("Keep-Alive", $"timeout=15,max={100 - reuses}");
+ Headers.Add(HttpHeaderNames.KeepAlive, $"timeout=15,max={100 - reuses}");
if (_context.Request.ProtocolVersion <= HttpVersion.Version10)
- HeaderCollection.Add("Connection", "keep-alive");
+ Headers.Add(HttpHeaderNames.Connection, "keep-alive");
}
return WriteHeaders();
@@ -308,8 +289,7 @@ private static string CookieToClientString(Cookie cookie)
{
result
.Append("; Expires=")
- .Append(cookie.Expires.ToUniversalTime().ToString("ddd, dd-MMM-yyyy HH:mm:ss", DateTimeFormatInfo.InvariantInfo))
- .Append(" GMT");
+ .Append(HttpDate.Format(cookie.Expires));
}
if (!string.IsNullOrEmpty(cookie.Path))
@@ -343,21 +323,21 @@ private void Close(bool force)
private string GetHeaderData()
{
var sb = new StringBuilder()
- .AppendFormat("HTTP/{0} {1} {2}\r\n", ProtocolVersion, _statusCode, StatusDescription);
+ .AppendFormat(CultureInfo.InvariantCulture, "HTTP/{0} {1} {2}\r\n", ProtocolVersion, _statusCode, StatusDescription);
- foreach (var key in HeaderCollection.AllKeys.Where(x => x != "Set-Cookie"))
- sb.AppendFormat("{0}: {1}\r\n", key, HeaderCollection[key]);
+ foreach (var key in Headers.AllKeys.Where(x => x != "Set-Cookie"))
+ sb.AppendFormat(CultureInfo.InvariantCulture, "{0}: {1}\r\n", key, Headers[key]);
if (_cookies != null)
{
foreach (var cookie in _cookies)
- sb.AppendFormat("Set-Cookie: {0}\r\n", CookieToClientString(cookie));
+ sb.AppendFormat(CultureInfo.InvariantCulture, "Set-Cookie: {0}\r\n", CookieToClientString(cookie));
}
- if (HeaderCollection.AllKeys.Contains("Set-Cookie"))
+ if (Headers.AllKeys.Contains(HttpHeaderNames.SetCookie))
{
- foreach (var cookie in CookieCollection.ParseResponse(HeaderCollection["Set-Cookie"]))
- sb.AppendFormat("Set-Cookie: {0}\r\n", CookieToClientString(cookie));
+ foreach (var cookie in CookieList.Parse(Headers[HttpHeaderNames.SetCookie]))
+ sb.AppendFormat(CultureInfo.InvariantCulture, "Set-Cookie: {0}\r\n", CookieToClientString(cookie));
}
return sb.Append("\r\n").ToString();
diff --git a/src/EmbedIO/Net/Internal/HttpListenerResponseHelper.cs b/src/EmbedIO/Net/Internal/HttpListenerResponseHelper.cs
new file mode 100644
index 000000000..06953f4e5
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/HttpListenerResponseHelper.cs
@@ -0,0 +1,106 @@
+namespace EmbedIO.Net.Internal
+{
+ internal static class HttpListenerResponseHelper
+ {
+ internal static string GetStatusDescription(int code)
+ {
+ switch (code)
+ {
+ case 100:
+ return "Continue";
+ case 101:
+ return "Switching Protocols";
+ case 102:
+ return "Processing";
+ case 200:
+ return "OK";
+ case 201:
+ return "Created";
+ case 202:
+ return "Accepted";
+ case 203:
+ return "Non-Authoritative Information";
+ case 204:
+ return "No Content";
+ case 205:
+ return "Reset Content";
+ case 206:
+ return "Partial Content";
+ case 207:
+ return "Multi-Status";
+ case 300:
+ return "Multiple Choices";
+ case 301:
+ return "Moved Permanently";
+ case 302:
+ return "Found";
+ case 303:
+ return "See Other";
+ case 304:
+ return "Not Modified";
+ case 305:
+ return "Use Proxy";
+ case 307:
+ return "Temporary Redirect";
+ case 400:
+ return "Bad Request";
+ case 401:
+ return "Unauthorized";
+ case 402:
+ return "Payment Required";
+ case 403:
+ return "Forbidden";
+ case 404:
+ return "Not Found";
+ case 405:
+ return "Method Not Allowed";
+ case 406:
+ return "Not Acceptable";
+ case 407:
+ return "Proxy Authentication Required";
+ case 408:
+ return "Request Timeout";
+ case 409:
+ return "Conflict";
+ case 410:
+ return "Gone";
+ case 411:
+ return "Length Required";
+ case 412:
+ return "Precondition Failed";
+ case 413:
+ return "Request Entity Too Large";
+ case 414:
+ return "Request-Uri Too Long";
+ case 415:
+ return "Unsupported Media Type";
+ case 416:
+ return "Requested Range Not Satisfiable";
+ case 417:
+ return "Expectation Failed";
+ case 422:
+ return "Unprocessable Entity";
+ case 423:
+ return "Locked";
+ case 424:
+ return "Failed Dependency";
+ case 500:
+ return "Internal Server Error";
+ case 501:
+ return "Not Implemented";
+ case 502:
+ return "Bad Gateway";
+ case 503:
+ return "Service Unavailable";
+ case 504:
+ return "Gateway Timeout";
+ case 505:
+ return "Http Version Not Supported";
+ case 507:
+ return "Insufficient Storage";
+ default:
+ return string.Empty;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpResponse.cs b/src/EmbedIO/Net/Internal/HttpResponse.cs
similarity index 72%
rename from src/Unosquare.Labs.EmbedIO/System.Net/HttpResponse.cs
rename to src/EmbedIO/Net/Internal/HttpResponse.cs
index 0bd4b6b8d..19165f24c 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpResponse.cs
+++ b/src/EmbedIO/Net/Internal/HttpResponse.cs
@@ -1,18 +1,18 @@
-namespace Unosquare.Net
-{
- using System;
- using Labs.EmbedIO;
- using System.Collections.Specialized;
- using System.Linq;
- using System.Net;
- using System.Text;
+using System;
+using System.Collections.Specialized;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Text;
+namespace EmbedIO.Net.Internal
+{
internal class HttpResponse
{
- internal const string ServerVersion = "embedio/2.0";
+ internal const string ServerVersion = "embedio/3.0";
internal HttpResponse(HttpStatusCode code)
- : this((int) code, HttpStatusDescription.Get(code), HttpVersion.Version11, new NameValueCollection())
+ : this((int) code, HttpListenerResponseHelper.GetStatusDescription((int)code), HttpVersion.Version11, new NameValueCollection())
{
}
@@ -33,19 +33,19 @@ private HttpResponse(int code, string reason, Version version, NameValueCollecti
public Version ProtocolVersion { get; }
- public void SetCookies(CookieCollection cookies)
+ public void SetCookies(ICookieCollection cookies)
{
foreach (var cookie in cookies)
- Headers.Add(HttpHeaderNames.SetCookie, cookie.ToString());
+ Headers.Add("Set-Cookie", cookie.ToString());
}
public override string ToString()
{
var output = new StringBuilder(64)
- .AppendFormat("HTTP/{0} {1} {2}\r\n", ProtocolVersion, StatusCode, Reason);
+ .AppendFormat(CultureInfo.InvariantCulture, "HTTP/{0} {1} {2}\r\n", ProtocolVersion, StatusCode, Reason);
foreach (var key in Headers.AllKeys)
- output.AppendFormat("{0}: {1}\r\n", key, Headers[key]);
+ output.AppendFormat(CultureInfo.InvariantCulture, "{0}: {1}\r\n", key, Headers[key]);
output.Append("\r\n");
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/ListenerPrefix.cs b/src/EmbedIO/Net/Internal/ListenerPrefix.cs
similarity index 96%
rename from src/Unosquare.Labs.EmbedIO/System.Net/ListenerPrefix.cs
rename to src/EmbedIO/Net/Internal/ListenerPrefix.cs
index f5891dfc2..21e14014c 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/ListenerPrefix.cs
+++ b/src/EmbedIO/Net/Internal/ListenerPrefix.cs
@@ -1,7 +1,8 @@
-namespace Unosquare.Net
-{
- using System;
+using System;
+using System.Globalization;
+namespace EmbedIO.Net.Internal
+{
internal sealed class ListenerPrefix
{
public ListenerPrefix(string uri)
@@ -27,7 +28,7 @@ public ListenerPrefix(string uri)
{
Host = uri.Substring(startHost, colon - startHost);
root = uri.IndexOf('/', colon, length - colon);
- Port = int.Parse(uri.Substring(colon + 1, root - colon - 1));
+ Port = int.Parse(uri.Substring(colon + 1, root - colon - 1), CultureInfo.InvariantCulture);
}
else
{
diff --git a/src/EmbedIO/Net/Internal/NetExtensions.cs b/src/EmbedIO/Net/Internal/NetExtensions.cs
new file mode 100644
index 000000000..18488ca8e
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/NetExtensions.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Linq;
+using Swan;
+
+namespace EmbedIO.Net.Internal
+{
+ ///
+ /// Represents some System.NET custom extensions.
+ ///
+ internal static class NetExtensions
+ {
+ internal static byte[] ToByteArray(this ushort value, Endianness order)
+ {
+ var bytes = BitConverter.GetBytes(value);
+ if (!order.IsHostOrder())
+ Array.Reverse(bytes);
+
+ return bytes;
+ }
+
+ internal static byte[] ToByteArray(this ulong value, Endianness order)
+ {
+ var bytes = BitConverter.GetBytes(value);
+ if (!order.IsHostOrder())
+ Array.Reverse(bytes);
+
+ return bytes;
+ }
+
+ internal static byte[] ToHostOrder(this byte[] source, Endianness sourceOrder)
+ {
+ if (source == null)
+ throw new ArgumentNullException(nameof(source));
+
+ return source.Length > 1 && !sourceOrder.IsHostOrder() ? source.Reverse().ToArray() : source;
+ }
+
+ internal static bool IsHostOrder(this Endianness order)
+ {
+ // true: !(true ^ true) or !(false ^ false)
+ // false: !(true ^ false) or !(false ^ true)
+ return !(BitConverter.IsLittleEndian ^ (order == Endianness.Little));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/RequestStream.cs b/src/EmbedIO/Net/Internal/RequestStream.cs
similarity index 96%
rename from src/Unosquare.Labs.EmbedIO/System.Net/RequestStream.cs
rename to src/EmbedIO/Net/Internal/RequestStream.cs
index 4ede009a7..1d31cbe9b 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/RequestStream.cs
+++ b/src/EmbedIO/Net/Internal/RequestStream.cs
@@ -1,9 +1,9 @@
-namespace Unosquare.Net
-{
- using System;
- using System.IO;
- using System.Runtime.InteropServices;
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+namespace EmbedIO.Net.Internal
+{
internal class RequestStream : Stream
{
private readonly Stream _stream;
@@ -68,7 +68,7 @@ public override int Read([In, Out] byte[] buffer, int offset, int count)
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
-
+
// Returns 0 if we can keep reading from the base stream,
// > 0 if we read something from the buffer.
// -1 if we had a content length set and we finished reading that many bytes.
diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/ResponseStream.cs b/src/EmbedIO/Net/Internal/ResponseStream.cs
similarity index 96%
rename from src/Unosquare.Labs.EmbedIO/System.Net/ResponseStream.cs
rename to src/EmbedIO/Net/Internal/ResponseStream.cs
index 0e6b9e232..d8e368656 100644
--- a/src/Unosquare.Labs.EmbedIO/System.Net/ResponseStream.cs
+++ b/src/EmbedIO/Net/Internal/ResponseStream.cs
@@ -1,12 +1,11 @@
-namespace Unosquare.Net
-{
- using System;
- using System.IO;
- using System.Runtime.InteropServices;
- using System.Text;
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
- internal class ResponseStream
- : Stream
+namespace EmbedIO.Net.Internal
+{
+ internal class ResponseStream : Stream
{
private static readonly byte[] Crlf = { 13, 10 };
@@ -42,55 +41,6 @@ public override long Position
set => throw new NotSupportedException();
}
- protected override void Dispose(bool disposing)
- {
- if (_disposed) return;
-
- _disposed = true;
-
- if (!disposing) return;
-
- var ms = GetHeaders();
- var chunked = _response.SendChunked;
-
- if (_stream.CanWrite)
- {
- try
- {
- byte[] bytes;
- if (ms != null)
- {
- var start = ms.Position;
- if (chunked && !_trailerSent)
- {
- bytes = GetChunkSizeBytes(0, true);
- ms.Position = ms.Length;
- ms.Write(bytes, 0, bytes.Length);
- }
-
- InternalWrite(ms.ToArray(), (int)start, (int)(ms.Length - start));
- _trailerSent = true;
- }
- else if (chunked && !_trailerSent)
- {
- bytes = GetChunkSizeBytes(0, true);
- InternalWrite(bytes, 0, bytes.Length);
- _trailerSent = true;
- }
- }
- catch (ObjectDisposedException)
- {
- // Ignored
- }
- catch (IOException)
- {
- // Ignore error due to connection reset by peer
- }
- }
-
- _response.Close();
- }
-
///
public override void Flush()
{
@@ -165,6 +115,55 @@ internal void InternalWrite(byte[] buffer, int offset, int count)
}
}
+ protected override void Dispose(bool disposing)
+ {
+ if (_disposed) return;
+
+ _disposed = true;
+
+ if (!disposing) return;
+
+ var ms = GetHeaders();
+ var chunked = _response.SendChunked;
+
+ if (_stream.CanWrite)
+ {
+ try
+ {
+ byte[] bytes;
+ if (ms != null)
+ {
+ var start = ms.Position;
+ if (chunked && !_trailerSent)
+ {
+ bytes = GetChunkSizeBytes(0, true);
+ ms.Position = ms.Length;
+ ms.Write(bytes, 0, bytes.Length);
+ }
+
+ InternalWrite(ms.ToArray(), (int)start, (int)(ms.Length - start));
+ _trailerSent = true;
+ }
+ else if (chunked && !_trailerSent)
+ {
+ bytes = GetChunkSizeBytes(0, true);
+ InternalWrite(bytes, 0, bytes.Length);
+ _trailerSent = true;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ // Ignored
+ }
+ catch (IOException)
+ {
+ // Ignore error due to connection reset by peer
+ }
+ }
+
+ _response.Close();
+ }
+
private static byte[] GetChunkSizeBytes(int size, bool final) => Encoding.UTF8.GetBytes($"{size:x}\r\n{(final ? "\r\n" : string.Empty)}");
private MemoryStream GetHeaders(bool closing = true)
diff --git a/src/EmbedIO/Net/Internal/StringExtensions.cs b/src/EmbedIO/Net/Internal/StringExtensions.cs
new file mode 100644
index 000000000..f8e0e6392
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/StringExtensions.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace EmbedIO.Net.Internal
+{
+ internal static class StringExtensions
+ {
+ private const string TokenSpecialChars = "()<>@,;:\\\"/[]?={} \t";
+
+ internal static bool IsToken(this string @this)
+ => @this.All(c => c >= 0x20 && c < 0x7f && TokenSpecialChars.IndexOf(c) < 0);
+
+ internal static IEnumerable SplitHeaderValue(this string @this, bool useCookieSeparators)
+ {
+ var len = @this.Length;
+
+ var buff = new StringBuilder(32);
+ var escaped = false;
+ var quoted = false;
+
+ for (var i = 0; i < len; i++)
+ {
+ var c = @this[i];
+
+ if (c == '"')
+ {
+ if (escaped)
+ escaped = false;
+ else
+ quoted = !quoted;
+ }
+ else if (c == '\\')
+ {
+ if (i < len - 1 && @this[i + 1] == '"')
+ escaped = true;
+ }
+ else if (c == ',' || (useCookieSeparators && c == ';'))
+ {
+ if (!quoted)
+ {
+ yield return buff.ToString();
+ buff.Length = 0;
+
+ continue;
+ }
+ }
+
+ buff.Append(c);
+ }
+
+ if (buff.Length > 0)
+ yield return buff.ToString();
+ }
+
+ internal static string Unquote(this string str)
+ {
+ var start = str.IndexOf('\"');
+ var end = str.LastIndexOf('\"');
+
+ if (start >= 0 && end >= 0)
+ str = str.Substring(start + 1, end - 1);
+
+ return str.Trim();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Net/Internal/SystemCookieCollection.cs b/src/EmbedIO/Net/Internal/SystemCookieCollection.cs
new file mode 100644
index 000000000..ade50ebcf
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/SystemCookieCollection.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+
+namespace EmbedIO.Net.Internal
+{
+ ///
+ /// Represents a wrapper for System.Net.CookieCollection .
+ ///
+ ///
+ internal sealed class SystemCookieCollection : ICookieCollection
+ {
+ private readonly CookieCollection _collection;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The cookie collection.
+ public SystemCookieCollection(CookieCollection collection)
+ {
+ _collection = collection;
+ }
+
+ ///
+ public int Count => _collection.Count;
+
+ ///
+ public bool IsSynchronized => _collection.IsSynchronized;
+
+ ///
+ public object SyncRoot => _collection.SyncRoot;
+
+ ///
+ public Cookie this[string name] => _collection[name];
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => _collection.OfType().GetEnumerator();
+
+ ///
+ public IEnumerator GetEnumerator() => _collection.GetEnumerator();
+
+ ///
+ public void CopyTo(Array array, int index) => _collection.CopyTo(array, index);
+
+ ///
+ public void CopyTo(Cookie[] array, int index) => _collection.CopyTo(array, index);
+
+ ///
+ public void Add(Cookie cookie) => _collection.Add(cookie);
+
+ ///
+ public bool Contains(Cookie cookie) => _collection.OfType().Contains(cookie);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Net/Internal/SystemHttpContext.cs b/src/EmbedIO/Net/Internal/SystemHttpContext.cs
new file mode 100644
index 000000000..4fde4cedf
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/SystemHttpContext.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Routing;
+using EmbedIO.Sessions;
+using EmbedIO.Utilities;
+using EmbedIO.WebSockets;
+using EmbedIO.WebSockets.Internal;
+using Swan.Logging;
+
+namespace EmbedIO.Net.Internal
+{
+ internal sealed class SystemHttpContext : IHttpContextImpl
+ {
+ private readonly System.Net.HttpListenerContext _context;
+
+ private readonly TimeKeeper _ageKeeper = new TimeKeeper();
+
+ private readonly Stack> _closeCallbacks = new Stack>();
+
+ private bool _isHandled;
+ private bool _closed;
+
+ public SystemHttpContext(System.Net.HttpListenerContext context)
+ {
+ _context = context;
+
+ Request = new SystemHttpRequest(_context);
+ User = _context.User;
+ Response = new SystemHttpResponse(_context);
+ Id = UniqueIdGenerator.GetNext();
+ LocalEndPoint = Request.LocalEndPoint;
+ RemoteEndPoint = Request.RemoteEndPoint;
+ }
+
+ public string Id { get; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ public long Age => _ageKeeper.ElapsedTime;
+
+ public IPEndPoint LocalEndPoint { get; }
+
+ public IPEndPoint RemoteEndPoint { get; }
+
+ public IHttpRequest Request { get; }
+
+ public RouteMatch Route { get; set; }
+
+ public string RequestedPath => Route.SubPath;
+
+ public IHttpResponse Response { get; }
+
+ public IPrincipal User { get; }
+
+ public ISessionProxy Session { get; set; }
+
+ public bool SupportCompressedRequests { get; set; }
+
+ public IDictionary Items { get; } = new Dictionary();
+
+ public bool IsHandled => _isHandled;
+
+ public MimeTypeProviderStack MimeTypeProviders { get; } = new MimeTypeProviderStack();
+
+ public void SetHandled() => _isHandled = true;
+
+ public void OnClose(Action callback)
+ {
+ if (_closed)
+ throw new InvalidOperationException("HTTP context has already been closed.");
+
+ _closeCallbacks.Push(Validate.NotNull(nameof(callback), callback));
+ }
+
+ public async Task AcceptWebSocketAsync(
+ IEnumerable requestedProtocols,
+ string acceptedProtocol,
+ int receiveBufferSize,
+ TimeSpan keepAliveInterval,
+ CancellationToken cancellationToken)
+ {
+ var context = await _context.AcceptWebSocketAsync(
+ acceptedProtocol,
+ receiveBufferSize,
+ keepAliveInterval)
+ .ConfigureAwait(false);
+ return new WebSocketContext(this, context.SecWebSocketVersion, requestedProtocols, acceptedProtocol, new SystemWebSocket(context.WebSocket), cancellationToken);
+ }
+
+ public void Close()
+ {
+ _closed = true;
+
+ // Always close the response stream no matter what.
+ Response.Close();
+
+ foreach (var callback in _closeCallbacks)
+ {
+ try
+ {
+ callback(this);
+ }
+ catch (Exception e)
+ {
+ e.Log("HTTP context", "[Id] Exception thrown by a HTTP context close callback.");
+ }
+ }
+ }
+
+ public string GetMimeType(string extension)
+ => MimeTypeProviders.GetMimeType(extension);
+
+ public bool TryDetermineCompression(string mimeType, out bool preferCompression)
+ => MimeTypeProviders.TryDetermineCompression(mimeType, out preferCompression);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Net/Internal/SystemHttpListener.cs b/src/EmbedIO/Net/Internal/SystemHttpListener.cs
new file mode 100644
index 000000000..5d1488316
--- /dev/null
+++ b/src/EmbedIO/Net/Internal/SystemHttpListener.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EmbedIO.Net.Internal
+{
+ ///
+ /// Represents a wrapper for Microsoft HTTP Listener.
+ ///
+ internal class SystemHttpListener : IHttpListener
+ {
+ private readonly System.Net.HttpListener _httpListener;
+
+ public SystemHttpListener(System.Net.HttpListener httpListener)
+ {
+ _httpListener = httpListener;
+ }
+
+ ///
+ public bool IgnoreWriteExceptions
+ {
+ get => _httpListener.IgnoreWriteExceptions;
+ set => _httpListener.IgnoreWriteExceptions = value;
+ }
+
+ ///
+ public List Prefixes => _httpListener.Prefixes.ToList();
+
+ ///
+ public bool IsListening => _httpListener.IsListening;
+
+ ///
+ public string Name { get; } = "Microsoft HTTP Listener";
+
+ ///
+ public void Start() => _httpListener.Start();
+
+ ///
+ public void Stop() => _httpListener.Stop();
+
+ ///
+ public void AddPrefix(string urlPrefix) => _httpListener.Prefixes.Add(urlPrefix);
+
+ ///
+ public async Task GetContextAsync(CancellationToken cancellationToken)
+ {
+ // System.Net.HttpListener.GetContextAsync may throw ObjectDisposedException
+ // when stopping a WebServer. This has been observed on Mono 5.20.1.19
+ // on Raspberry Pi, but the fact remains that the method does not take
+ // a CancellationToken as parameter, and WebServerBase<>.RunAsync counts on it.
+ System.Net.HttpListenerContext context;
+ try
+ {
+ context = await _httpListener.GetContextAsync().ConfigureAwait(false);
+ }
+ catch (Exception e) when (cancellationToken.IsCancellationRequested)
+ {
+ throw new OperationCanceledException(
+ "Probable cancellation detected by catching an exception in System.Net.HttpListener.GetContextAsync",
+ e,
+ cancellationToken);
+ }
+
+ return new SystemHttpContext(context);
+ }
+
+ void IDisposable.Dispose() => ((IDisposable)_httpListener)?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/HttpRequest.cs b/src/EmbedIO/Net/Internal/SystemHttpRequest.cs
similarity index 73%
rename from src/Unosquare.Labs.EmbedIO/HttpRequest.cs
rename to src/EmbedIO/Net/Internal/SystemHttpRequest.cs
index d7f8873b0..851a76f9d 100644
--- a/src/Unosquare.Labs.EmbedIO/HttpRequest.cs
+++ b/src/EmbedIO/Net/Internal/SystemHttpRequest.cs
@@ -1,27 +1,29 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System;
- using System.Text;
- using System.Collections.Specialized;
- using System.IO;
- using System.Net;
+using System;
+using System.Collections.Specialized;
+using System.IO;
+using System.Net;
+using System.Text;
+namespace EmbedIO.Net.Internal
+{
///
/// Represents a wrapper for HttpListenerContext.Request.
///
- ///
- public class HttpRequest : IHttpRequest
+ ///
+ public class SystemHttpRequest : IHttpRequest
{
- private readonly HttpListenerRequest _request;
+ private readonly System.Net.HttpListenerRequest _request;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
/// The context.
- public HttpRequest(HttpListenerContext context)
+ public SystemHttpRequest(System.Net.HttpListenerContext context)
{
_request = context.Request;
- Cookies = new CookieCollection(_request.Cookies);
+ Enum.TryParse(_request.HttpMethod.Trim(), true, out var verb);
+ HttpVerb = verb;
+ Cookies = new SystemCookieCollection(_request.Cookies);
LocalEndPoint = _request.LocalEndPoint;
RemoteEndPoint = _request.RemoteEndPoint;
}
@@ -47,6 +49,9 @@ public HttpRequest(HttpListenerContext context)
///
public string HttpMethod => _request.HttpMethod;
+ ///
+ public HttpVerbs HttpVerb { get; }
+
///
public Uri Url => _request.Url;
@@ -62,6 +67,9 @@ public HttpRequest(HttpListenerContext context)
///
public IPEndPoint RemoteEndPoint { get; }
+ ///
+ public bool IsSecureConnection => _request.IsSecureConnection;
+
///
public bool IsLocal => _request.IsLocal;
@@ -85,8 +93,5 @@ public HttpRequest(HttpListenerContext context)
///
public Uri UrlReferrer => _request.UrlReferrer;
-
- ///
- public Guid RequestTraceIdentifier => _request.RequestTraceIdentifier;
}
}
\ No newline at end of file
diff --git a/src/Unosquare.Labs.EmbedIO/HttpResponse.cs b/src/EmbedIO/Net/Internal/SystemHttpResponse.cs
similarity index 72%
rename from src/Unosquare.Labs.EmbedIO/HttpResponse.cs
rename to src/EmbedIO/Net/Internal/SystemHttpResponse.cs
index c9a99a0e3..cb07a82a0 100644
--- a/src/Unosquare.Labs.EmbedIO/HttpResponse.cs
+++ b/src/EmbedIO/Net/Internal/SystemHttpResponse.cs
@@ -1,31 +1,30 @@
-namespace Unosquare.Labs.EmbedIO
-{
- using System;
- using System.Collections.Specialized;
- using System.Text;
- using System.IO;
- using System.Net;
+using System;
+using System.IO;
+using System.Net;
+using System.Text;
+namespace EmbedIO.Net.Internal
+{
///
/// Represents a wrapper for HttpListenerContext.Response.
///
///
- public class HttpResponse : IHttpResponse
+ public class SystemHttpResponse : IHttpResponse
{
- private readonly HttpListenerResponse _response;
+ private readonly System.Net.HttpListenerResponse _response;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
/// The context.
- public HttpResponse(HttpListenerContext context)
+ public SystemHttpResponse(System.Net.HttpListenerContext context)
{
_response = context.Response;
- Cookies = new CookieCollection(_response.Cookies);
+ Cookies = new SystemCookieCollection(_response.Cookies);
}
///
- public NameValueCollection Headers => _response.Headers;
+ public WebHeaderCollection Headers => _response.Headers;
///
public int StatusCode
@@ -67,7 +66,14 @@ public bool KeepAlive
get => _response.KeepAlive;
set => _response.KeepAlive = value;
}
-
+
+ ///
+ public bool SendChunked
+ {
+ get => _response.SendChunked;
+ set => _response.SendChunked = value;
+ }
+
///
public Version ProtocolVersion
{
@@ -83,10 +89,7 @@ public string StatusDescription
}
///
- public void AddHeader(string headerName, string value) => _response.AddHeader(headerName, value);
-
- ///
- public void SetCookie(Cookie sessionCookie) => _response.SetCookie(sessionCookie);
+ public void SetCookie(Cookie cookie) => _response.SetCookie(cookie);
///
public void Close() => _response.OutputStream?.Dispose();
diff --git a/src/EmbedIO/RequestDeserializer.cs b/src/EmbedIO/RequestDeserializer.cs
new file mode 100644
index 000000000..4dcdeadfa
--- /dev/null
+++ b/src/EmbedIO/RequestDeserializer.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Threading.Tasks;
+using Swan.Logging;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides standard request deserialization callbacks.
+ ///
+ public static class RequestDeserializer
+ {
+ ///
+ /// The default request deserializer used by EmbedIO.
+ /// Equivalent to .
+ ///
+ /// The expected type of the deserialized data.
+ /// The whose request body is to be deserialized.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be the deserialized data.
+ public static Task Default(IHttpContext context) => Json(context);
+
+ ///
+ /// Asynchronously deserializes a request body in JSON format.
+ ///
+ /// The expected type of the deserialized data.
+ /// The whose request body is to be deserialized.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be the deserialized data.
+ public static async Task Json(IHttpContext context)
+ {
+ string body;
+ using (var reader = context.OpenRequestText())
+ {
+ body = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+
+ try
+ {
+ return Swan.Formatters.Json.Deserialize(body);
+ }
+ catch (FormatException)
+ {
+ $"[{context.Id}] Cannot convert JSON request body to {typeof(TData).Name}, sending 400 Bad Request..."
+ .Warn($"{nameof(RequestDeserializer)}.{nameof(Json)}");
+
+ throw HttpException.BadRequest("Incorrect request data format.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/RequestDeserializerCallback`1.cs b/src/EmbedIO/RequestDeserializerCallback`1.cs
new file mode 100644
index 000000000..8313a6f4f
--- /dev/null
+++ b/src/EmbedIO/RequestDeserializerCallback`1.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// A callback used to deserialize a HTTP request body.
+ ///
+ /// The expected type of the deserialized data.
+ /// The whose request body is to be deserialized.
+ /// A Task , representing the ongoing operation,
+ /// whose result will be the deserialized data.
+ public delegate Task RequestDeserializerCallback(IHttpContext context);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/RequestHandler.cs b/src/EmbedIO/RequestHandler.cs
new file mode 100644
index 000000000..0fd1bcd8e
--- /dev/null
+++ b/src/EmbedIO/RequestHandler.cs
@@ -0,0 +1,61 @@
+using System;
+using EmbedIO.Internal;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides standard request handler callbacks.
+ ///
+ ///
+ public static class RequestHandler
+ {
+ ///
+ /// Returns an exception object that, when thrown from a module's
+ /// HandleRequestAsync method, will cause the HTTP context
+ /// to be passed down along the module chain, regardless of the value of the module's
+ /// IsFinalHandler property.
+ ///
+ /// A newly-created .
+ public static Exception PassThrough() => new RequestHandlerPassThroughException();
+
+ ///
+ /// Returns a that unconditionally sends a 401 Unauthorized response.
+ ///
+ /// A message to include in the response.
+ /// A .
+ public static RequestHandlerCallback ThrowUnauthorized(string message = null)
+ => _ => throw HttpException.Unauthorized(message);
+
+ ///
+ /// Returns a that unconditionally sends a 403 Forbidden response.
+ ///
+ /// A message to include in the response.
+ /// A .
+ public static RequestHandlerCallback ThrowForbidden(string message = null)
+ => _ => throw HttpException.Forbidden(message);
+
+ ///
+ /// Returns a that unconditionally sends a 400 Bad Request response.
+ ///
+ /// A message to include in the response.
+ /// A .
+ public static RequestHandlerCallback ThrowBadRequest(string message = null)
+ => _ => throw HttpException.BadRequest(message);
+
+ ///
+ /// Returns a that unconditionally sends a 404 Not Found response.
+ ///
+ /// A message to include in the response.
+ /// A .
+ public static RequestHandlerCallback ThrowNotFound(string message = null)
+ => _ => throw HttpException.NotFound(message);
+
+ ///
+ /// Returns a that unconditionally sends a 405 Method Not Allowed response.
+ ///
+ /// A message to include in the response.
+ /// A .
+ public static RequestHandlerCallback ThrowMethodNotAllowed(string message = null)
+ => _ => throw HttpException.MethodNotAllowed();
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/RequestHandlerCallback.cs b/src/EmbedIO/RequestHandlerCallback.cs
new file mode 100644
index 000000000..6e95234d8
--- /dev/null
+++ b/src/EmbedIO/RequestHandlerCallback.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// A callback used to handle a request.
+ ///
+ /// An interface representing the context of the request.
+ /// A representing the ongoing operation.
+ public delegate Task RequestHandlerCallback(IHttpContext context);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ResponseSerializer.cs b/src/EmbedIO/ResponseSerializer.cs
new file mode 100644
index 000000000..99d26db31
--- /dev/null
+++ b/src/EmbedIO/ResponseSerializer.cs
@@ -0,0 +1,34 @@
+using System.Text;
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// Provides standard response serializer callbacks.
+ ///
+ ///
+ public static class ResponseSerializer
+ {
+ ///
+ /// The default response serializer callback used by EmbedIO.
+ /// Equivalent to .
+ ///
+ public static readonly ResponseSerializerCallback Default = Json;
+
+ ///
+ /// Serializes data in JSON format to a HTTP response,
+ /// using the utility class.
+ ///
+ /// The HTTP context of the request.
+ /// The data to serialize.
+ /// A representing the ongoing operation.
+ public static async Task Json(IHttpContext context, object data)
+ {
+ context.Response.ContentType = MimeType.Json;
+ using (var text = context.OpenResponseText(Encoding.UTF8))
+ {
+ await text.WriteAsync(Swan.Formatters.Json.Serialize(data)).ConfigureAwait(false);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/ResponseSerializerCallback.cs b/src/EmbedIO/ResponseSerializerCallback.cs
new file mode 100644
index 000000000..0d8277a66
--- /dev/null
+++ b/src/EmbedIO/ResponseSerializerCallback.cs
@@ -0,0 +1,12 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO
+{
+ ///
+ /// A callback used to serialize data to a HTTP response.
+ ///
+ /// The HTTP context of the request.
+ /// The data to serialize.
+ /// A representing the ongoing operation.
+ public delegate Task ResponseSerializerCallback(IHttpContext context, object data);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/Route.cs b/src/EmbedIO/Routing/Route.cs
new file mode 100644
index 000000000..9c229f301
--- /dev/null
+++ b/src/EmbedIO/Routing/Route.cs
@@ -0,0 +1,324 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using EmbedIO.WebApi;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Provides utility methods to work with routes.
+ ///
+ ///
+ ///
+ ///
+ public static class Route
+ {
+ // Characters in ValidParameterNameChars MUST be in ascending ordinal order!
+ private static readonly char[] ValidParameterNameChars =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".ToCharArray();
+
+ // Passed to string.Split to divide a route in segments.
+ private static readonly char[] SlashSeparator = { '/'};
+
+ ///
+ /// Determines whether a string is a valid route parameter name.
+ /// To be considered a valid route parameter name, the specified string:
+ ///
+ /// must not be ;
+ /// must not be the empty string;
+ /// must consist entirely of decimal digits, upper- or lower-case
+ /// letters of the English alphabet, or underscore ('_' ) characters;
+ /// must not start with a decimal digit.
+ ///
+ ///
+ /// The value.
+ /// if is a valid route parameter;
+ /// otherwise, .
+ public static bool IsValidParameterName(string value)
+ => !string.IsNullOrEmpty(value)
+ && value[0] > '9'
+ && !value.Any(c => c < '0' || c > 'z' || Array.BinarySearch(ValidParameterNameChars, c) < 0);
+
+ ///
+ /// Determines whether a string is a valid route.
+ /// To be considered a valid route, the specified string:
+ ///
+ /// must not be ;
+ /// must not be the empty string;
+ /// must start with a slash ('/' ) character;
+ /// if a base route, must end with a slash ('/' ) character;
+ /// if not a base route, must not end with a slash ('/' ) character,
+ /// unless it is the only character in the string;
+ /// must not contain consecutive runs of two or more slash ('/' ) characters;
+ /// may contain one or more parameter specifications.
+ ///
+ /// Each parameter specification must be enclosed in curly brackets ('{'
+ /// and '}' . No whitespace is allowed inside a parameter specification.
+ /// Two parameter specifications must be separated by literal text.
+ /// A parameter specification consists of a valid parameter name, optionally
+ /// followed by a '?' character to signify that it will also match an empty string.
+ /// If '?' is not present, a parameter by default will NOT match an empty string.
+ /// See for the definition of a valid parameter name.
+ /// To include a literal open curly bracket in the route, it must be doubled ("{{" ).
+ /// A literal closed curly bracket ('}' ) may be included in the route as-is.
+ /// A segment of a base route cannot consist only of an optional parameter.
+ ///
+ /// The route to check.
+ /// if checking for a base route;
+ /// otherwise, .
+ /// if is a valid route;
+ /// otherwise, .
+ public static bool IsValid(string route, bool isBaseRoute) => ValidateInternal(nameof(route), route, isBaseRoute) == null;
+
+ // Check the validity of a route by parsing it without storing the results.
+ // Returns: ArgumentNullException, ArgumentException, null if OK
+ internal static Exception ValidateInternal(string argumentName, string value, bool isBaseRoute)
+ {
+ switch (ParseInternal(value, isBaseRoute, null))
+ {
+ case ArgumentNullException _:
+ return new ArgumentNullException(argumentName);
+
+ case FormatException formatException:
+ return new ArgumentException(formatException.Message, argumentName);
+
+ case Exception exception:
+ return exception;
+
+ default:
+ return null; // Unreachable, but the compiler doesn't know.
+ }
+ }
+
+ // Validate and parse a route, constructing a Regex pattern.
+ // setResult will be called at the end with the isBaseRoute flag, parameter names and the constructed pattern.
+ // Returns: ArgumentNullException, FormatException, null if OK
+ internal static Exception ParseInternal(string route, bool isBaseRoute, Action, string> setResult)
+ {
+ if (route == null)
+ return new ArgumentNullException(nameof(route));
+
+ if (route.Length == 0)
+ return new FormatException("Route is empty.");
+
+ if (route[0] != '/')
+ return new FormatException("Route does not start with a slash.");
+
+ /*
+ * Regex options set at start of pattern:
+ * IgnoreCase : no
+ * Multiline : no
+ * Singleline : yes
+ * ExplicitCapture : yes
+ * IgnorePatternWhitespace : no
+ * See https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-options
+ * See https://docs.microsoft.com/en-us/dotnet/standard/base-types/grouping-constructs-in-regular-expressions#group_options
+ */
+ const string InitialRegexOptions = "(?sn-imx)";
+
+ // If setResult is null we don't need the StringBuilder.
+ var sb = setResult == null ? null : new StringBuilder("^");
+
+ var parameterNames = new List();
+ if (route.Length == 1)
+ {
+ // If the route consists of a single slash, only a single slash will match.
+ sb?.Append(isBaseRoute ? "/" : "/$");
+ }
+ else
+ {
+ // First of all divide the route in segments.
+ // Segments are separated by slashes.
+ // The route is not necessarily normalized, so there could be runs of consecutive slashes.
+ var segmentCount = 0;
+ var optionalSegmentCount = 0;
+ foreach (var segment in GetSegments(route))
+ {
+ segmentCount++;
+
+ // Parse the segment, looking alternately for a '{', that opens a parameter specification,
+ // then for a '}', that closes it.
+ // Characters outside parameter specifications are Regex-escaped and added to the pattern.
+ // A parameter specification consists of a parameter name, optionally followed by '?'
+ // to indicate that an empty parameter will match.
+ // The default is to NOT match empty parameters, consistently with ASP.NET and EmbedIO version 2.
+ // More syntax rules:
+ // - There cannot be two parameters without literal text in between.
+ // - If a segment consists ONLY of an OPTIONAL parameter, then the slash preceding it is optional too.
+ var inParameterSpec = false;
+ var afterParameter = false;
+ for (var position = 0; ;)
+ {
+ if (inParameterSpec)
+ {
+ // Look for end of spec, bail out if not found.
+ var closePosition = segment.IndexOf('}', position);
+ if (closePosition < 0)
+ return new FormatException("Route syntax error: unclosed parameter specification.");
+
+ // Parameter spec cannot be empty.
+ if (closePosition == position)
+ return new FormatException("Route syntax error: empty parameter specification.");
+
+ // Check the last character:
+ // {name} means empty parameter does not match
+ // {name?} means empty parameter matches
+ // If '?'is found, the parameter name ends before it
+ var nameEndPosition = closePosition;
+ var allowEmpty = false;
+ if (segment[closePosition - 1] == '?')
+ {
+ allowEmpty = true;
+ nameEndPosition--;
+ }
+
+ // Bail out if only '?' is found inside the spec.
+ if (nameEndPosition == position)
+ return new FormatException("Route syntax error: missing parameter name.");
+
+ // Extract the parameter name.
+ var parameterName = segment.Substring(position, nameEndPosition - position);
+
+ // Ensure that the parameter name contains only valid characters.
+ if (!IsValidParameterName(parameterName))
+ return new FormatException("Route syntax error: parameter name contains one or more invalid characters.");
+
+ // Ensure that the parameter name is not a duplicate.
+ if (parameterNames.Contains(parameterName))
+ return new FormatException("Route syntax error: duplicate parameter name.");
+
+ // The spec is valid, so add the parameter to the list.
+ parameterNames.Add(parameterName);
+
+ // Append a capturing group with the same name to the pattern.
+ // Parameters must be made of non-slash characters ("[^/]")
+ // and must match non-greedily ("*?" if optional, "+?" if non optional).
+ // Position will be 1 at the start, not 0, because we've skipped the opening '{'.
+ if (allowEmpty && position == 1 && closePosition == segment.Length - 1)
+ {
+ if (isBaseRoute)
+ return new FormatException("No segment of a base route can be optional.");
+
+ // If the segment consists only of an optional parameter,
+ // then the slash preceding the segment is optional as well.
+ // In this case the parameter must match only is not empty,
+ // because it's (slash + parameter) that is optional.
+ sb?.Append("(/(?<").Append(parameterName).Append(">[^/]+?))?");
+ optionalSegmentCount++;
+ }
+ else
+ {
+ // If at the start of a segment, don't forget the slash!
+ // Position will be 1 at the start, not 0, because we've skipped the opening '{'.
+ if (position == 1)
+ sb?.Append('/');
+
+ sb?.Append("(?<").Append(parameterName).Append(">[^/]").Append(allowEmpty ? '*' : '+').Append("?)");
+ }
+
+ // Go on with parsing.
+ position = closePosition + 1;
+ inParameterSpec = false;
+ afterParameter = true;
+ }
+ else
+ {
+ // Look for start of parameter spec.
+ var openPosition = segment.IndexOf('{', position);
+ if (openPosition < 0)
+ {
+ // If at the start of a segment, don't forget the slash.
+ if (position == 0)
+ sb?.Append('/');
+
+ // No more parameter specs: escape the remainder of the string
+ // and add it to the pattern.
+ sb?.Append(Regex.Escape(segment.Substring(position)));
+ break;
+ }
+
+ var nextPosition = openPosition + 1;
+ if (nextPosition < segment.Length && segment[nextPosition] == '{')
+ {
+ // If another identical char follows, treat the two as a single literal char.
+ // If at the start of a segment, don't forget the slash!
+ if (position == 0)
+ sb?.Append('/');
+
+ sb?.Append(@"\\{");
+ }
+ else if (afterParameter && openPosition == position)
+ {
+ // If a parameter immediately follows another parameter,
+ // with no literal text in between, it's a syntax error.
+ return new FormatException("Route syntax error: parameters must be separated by literal text.");
+ }
+ else
+ {
+ // If at the start of a segment, don't forget the slash,
+ // but only if there actually is some literal text.
+ // Otherwise let the parameter spec parsing code deal with the slash,
+ // because we don't know whether this is an optional segment yet.
+ if (position == 0 && openPosition > 0)
+ sb?.Append('/');
+
+ // Escape the part of the pattern outside the parameter spec
+ // and add it to the pattern.
+ sb?.Append(Regex.Escape(segment.Substring(position, openPosition - position)));
+ inParameterSpec = true;
+ }
+
+ // Go on parsing.
+ position = nextPosition;
+ afterParameter = false;
+ }
+ }
+ }
+
+ // Close the pattern
+ sb?.Append(isBaseRoute ? "(/|$)" : "$");
+
+ // If all segments are optional segments, "/" must match too.
+ if (optionalSegmentCount == segmentCount)
+ sb?.Insert(0, "(/$)|(").Append(')');
+ }
+
+ // Pass the results to the callback if needed.
+ setResult?.Invoke(isBaseRoute, parameterNames, InitialRegexOptions + sb);
+
+ // Everything's fine, thus no exception.
+ return null;
+ }
+
+ // Enumerate the segments of a route, ignoring consecutive slashes.
+ private static IEnumerable GetSegments(string route)
+ {
+ var length = route.Length;
+ var position = 0;
+ for (; ; )
+ {
+ while (route[position] == '/')
+ {
+ position++;
+ if (position >= length)
+ break;
+ }
+
+ if (position >= length)
+ break;
+
+ var slashPosition = route.IndexOf('/', position);
+ if (slashPosition < 0)
+ {
+ yield return route.Substring(position);
+ break;
+ }
+
+ yield return route.Substring(position, slashPosition - position);
+ position = slashPosition;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteAttribute.cs b/src/EmbedIO/Routing/RouteAttribute.cs
new file mode 100644
index 000000000..b0daad33b
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteAttribute.cs
@@ -0,0 +1,40 @@
+using System;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Decorate methods within controllers with this attribute in order to make them callable from the Web API Module
+ /// Method Must match the WebServerModule.
+ ///
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+ public class RouteAttribute : Attribute
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The verb.
+ /// The route.
+ /// is .
+ ///
+ /// is empty.
+ /// - or -
+ /// does not start with a slash (/ ) character.
+ ///
+ public RouteAttribute(HttpVerbs verb, string route)
+ {
+ Verb = verb;
+ Route = Validate.Route(nameof(route), route, false);
+ }
+
+ ///
+ /// Gets the HTTP verb handled by a method with this attribute.
+ ///
+ public HttpVerbs Verb { get; }
+
+ ///
+ /// Gets the route handled by a method with this attribute.
+ ///
+ public string Route { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteHandlerCallback.cs b/src/EmbedIO/Routing/RouteHandlerCallback.cs
new file mode 100644
index 000000000..9867642bf
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteHandlerCallback.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Base class for callbacks used to handle routed requests.
+ ///
+ /// A interface representing the context of the request.
+ /// The matched route.
+ /// A representing the ongoing operation.
+ ///
+ public delegate Task RouteHandlerCallback(IHttpContext context, RouteMatch route);
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteMatch.cs b/src/EmbedIO/Routing/RouteMatch.cs
new file mode 100644
index 000000000..bd9fb6787
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteMatch.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Represents a route resolved by a .
+ /// This class may be used both as a dictionary of route parameter names and values,
+ /// and a list of the values.
+ /// Because of its double nature, this class cannot be enumerated directly. However,
+ /// you may use the property to iterate over name / value pairs, and the
+ /// property to iterate over values.
+ /// When enumerated in a non-generic fashion via the interface,
+ /// this class iterates over name / value pairs.
+ ///
+#pragma warning disable CA1710 // Rename class to end in "Collection"
+ public sealed class RouteMatch : IReadOnlyList, IReadOnlyDictionary
+#pragma warning restore CA1710
+ {
+ private static readonly IReadOnlyList EmptyStringList = Array.Empty();
+
+ private readonly IReadOnlyList _values;
+
+ internal RouteMatch(string path, IReadOnlyList names, IReadOnlyList values, string subPath)
+ {
+ Path = path;
+ Names = names;
+ _values = values;
+ SubPath = subPath;
+ }
+
+ ///
+ /// Gets the URL path that was successfully matched against the route.
+ ///
+ public string Path { get; }
+
+ ///
+ /// For a base route, gets the part of that follows the matched route;
+ /// for a non-base route, this property is always .
+ ///
+ public string SubPath { get; }
+
+ ///
+ /// Gets a list of the names of the route's parameters.
+ ///
+ public IReadOnlyList Names { get; }
+
+ ///
+ public int Count => _values.Count;
+
+ ///
+ public IEnumerable Keys => Names;
+
+ ///
+ public IEnumerable Values => _values;
+
+ ///
+ /// Gets an interface that can be used
+ /// to iterate over name / value pairs.
+ ///
+ public IEnumerable> Pairs => this;
+
+ ///
+ public string this[int index] => _values[index];
+
+ ///
+ public string this[string key]
+ {
+ get
+ {
+ var count = Names.Count;
+ for (var i = 0; i < count; i++)
+ {
+ if (Names[i] == key)
+ {
+ return _values[i];
+ }
+ }
+
+ throw new KeyNotFoundException("The parameter name was not found.");
+ }
+ }
+
+ ///
+ /// Returns a object equal to the one
+ /// that would result by matching the specified URL path against a
+ /// base route of "/" .
+ ///
+ /// The URL path to match.
+ /// A newly-constructed .
+ ///
+ /// This method assumes that
+ /// is a valid, non-base URL path or route. Otherwise, the behavior of this method
+ /// is unspecified.
+ /// Ensure that you validate before
+ /// calling this method, using either
+ /// or .
+ ///
+ public static RouteMatch UnsafeFromRoot(string urlPath)
+ => new RouteMatch(urlPath, EmptyStringList, EmptyStringList, urlPath);
+
+ ///
+ /// Returns a object equal to the one
+ /// that would result by matching the specified URL path against
+ /// the specified parameterless base route.
+ ///
+ /// The base route to match against.
+ /// The URL path to match.
+ /// A newly-constructed .
+ ///
+ /// This method assumes that is a
+ /// valid base URL path, and
+ /// is a valid, non-base URL path or route. Otherwise, the behavior of this method
+ /// is unspecified.
+ /// Ensure that you validate both parameters before
+ /// calling this method, using either
+ /// or .
+ ///
+ public static RouteMatch UnsafeFromBasePath(string baseUrlPath, string urlPath)
+ {
+ var subPath = UrlPath.UnsafeStripPrefix(urlPath, baseUrlPath);
+ return subPath == null ? null : new RouteMatch(urlPath, EmptyStringList, EmptyStringList, "/" + subPath);
+ }
+
+ ///
+ public bool ContainsKey(string key) => Names.Any(n => n == key);
+
+ ///
+ public bool TryGetValue(string key, out string value)
+ {
+ var count = Names.Count;
+ for (var i = 0; i < count; i++)
+ {
+ if (Names[i] == key)
+ {
+ value = _values[i];
+ return true;
+ }
+ }
+
+ value = null;
+ return false;
+ }
+
+ ///
+ /// Returns the index of the parameter with the specified name.
+ ///
+ /// The parameter name.
+ /// The index of the parameter, or -1 if none of the
+ /// route parameters have the specified name.
+ public int IndexOf(string name)
+ {
+ var count = Names.Count;
+ for (var i = 0; i < count; i++)
+ {
+ if (Names[i] == name)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ ///
+ IEnumerator> IEnumerable>.GetEnumerator()
+ => Names.Zip(_values, (n, v) => new KeyValuePair(n, v)).GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => Pairs.GetEnumerator();
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteMatcher.cs b/src/EmbedIO/Routing/RouteMatcher.cs
new file mode 100644
index 000000000..28ceb7bb6
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteMatcher.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+using EmbedIO.Utilities;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Matches URL paths against a route.
+ ///
+ public sealed class RouteMatcher
+ {
+ private static readonly object SyncRoot = new object();
+ private static readonly Dictionary Cache = new Dictionary(StringComparer.Ordinal);
+
+ private readonly Regex _regex;
+
+ private RouteMatcher(bool isBaseRoute, string route, string pattern, IReadOnlyList parameterNames)
+ {
+ IsBaseRoute = isBaseRoute;
+ Route = route;
+ ParameterNames = parameterNames;
+ _regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ }
+
+ ///
+ /// Gets a value indicating whether the property
+ /// is a base route.
+ ///
+ public bool IsBaseRoute { get; }
+
+ ///
+ /// Gets the route this instance matches URL paths against.
+ ///
+ public string Route { get; }
+
+ ///
+ /// Gets the names of the route's parameters.
+ ///
+ public IReadOnlyList ParameterNames { get; }
+
+ ///
+ /// Constructs an instance of by parsing the specified route.
+ /// If the same route was previously parsed and the method has not been called since,
+ /// this method obtains an instance from a static cache.
+ ///
+ /// The route to parse.
+ /// if the route to parse
+ /// is a base route; otherwise, .
+ /// A newly-constructed instance of
+ /// that will match URL paths against .
+ /// is .
+ /// is not a valid route.
+ ///
+ ///
+ public static RouteMatcher Parse(string route, bool isBaseRoute)
+ {
+ var exception = TryParseInternal(route, isBaseRoute, out var result);
+ if (exception != null)
+ throw exception;
+
+ return result;
+ }
+
+ ///
+ /// Attempts to obtain an instance of by parsing the specified route.
+ /// If the same route was previously parsed and the method has not been called since,
+ /// this method obtains an instance from a static cache.
+ ///
+ /// The route to parse.
+ /// if the route to parse
+ /// is a base route; otherwise, .
+ /// When this method returns , a newly-constructed instance of
+ /// that will match URL paths against ; otherwise, .
+ /// This parameter is passed uninitialized.
+ /// if parsing was successful; otherwise, .
+ ///
+ ///
+ public static bool TryParse(string route, bool isBaseRoute, out RouteMatcher result)
+ => TryParseInternal(route, isBaseRoute, out result) == null;
+
+ ///
+ /// Clears 's internal instance cache.
+ ///
+ ///
+ ///
+ public static void ClearCache()
+ {
+ lock (SyncRoot)
+ {
+ Cache.Clear();
+ }
+ }
+
+ ///
+ /// Matches the specified URL path against
+ /// and extracts values for the route's parameters.
+ ///
+ /// The URL path to match.
+ /// If the match is successful, a object;
+ /// otherwise, .
+ public RouteMatch Match(string path)
+ {
+ if (path == null)
+ return null;
+
+ // Optimize for parameterless base routes
+ if (IsBaseRoute)
+ {
+ if (Route.Length == 1)
+ return RouteMatch.UnsafeFromRoot(path);
+
+ if (ParameterNames.Count == 0)
+ return RouteMatch.UnsafeFromBasePath(Route, path);
+ }
+
+ var match = _regex.Match(path);
+ if (!match.Success)
+ return null;
+
+ return new RouteMatch(
+ path,
+ ParameterNames,
+ match.Groups.Cast().Skip(1).Select(g => WebUtility.UrlDecode(g.Value)).ToArray(),
+ IsBaseRoute ? "/" + path.Substring(match.Groups[0].Length) : null);
+ }
+
+ private static Exception TryParseInternal(string route, bool isBaseRoute, out RouteMatcher result)
+ {
+ lock (SyncRoot)
+ {
+ string pattern = null;
+ var parameterNames = new List();
+ var exception = Routing.Route.ParseInternal(route, isBaseRoute, (_, n, p) => {
+ parameterNames.AddRange(n);
+ pattern = p;
+ });
+ if (exception != null)
+ {
+ result = null;
+ return exception;
+ }
+
+ route = UrlPath.UnsafeNormalize(route, isBaseRoute);
+ if (Cache.TryGetValue(route, out result))
+ return null;
+
+ result = new RouteMatcher(isBaseRoute, route, pattern, parameterNames);
+ Cache.Add(route, result);
+ return null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteResolutionResult.cs b/src/EmbedIO/Routing/RouteResolutionResult.cs
new file mode 100644
index 000000000..198d0598b
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteResolutionResult.cs
@@ -0,0 +1,35 @@
+namespace EmbedIO.Routing
+{
+ ///
+ /// Represents the outcome of resolving a context and a path against a route.
+ ///
+ public enum RouteResolutionResult
+ {
+ /* DO NOT reorder members!
+ * RouteNotMatched < NoHandlerSelected < NoHandlerSuccessful < Success
+ *
+ * See comments in RouteResolverBase<,>.ResolveAsync for further explanation.
+ */
+
+ ///
+ /// The route didn't match.
+ ///
+ RouteNotMatched,
+
+ ///
+ /// The route did match, but no registered handler was suitable for the context.
+ ///
+ NoHandlerSelected,
+
+ ///
+ /// The route matched and one or more suitable handlers were found,
+ /// but none of them returned .
+ ///
+ NoHandlerSuccessful,
+
+ ///
+ /// The route has been resolved.
+ ///
+ Success,
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteResolverBase`1.cs b/src/EmbedIO/Routing/RouteResolverBase`1.cs
new file mode 100644
index 000000000..7c4d8c6df
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteResolverBase`1.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+using Swan.Configuration;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Implements the logic for resolving the requested path of a HTTP context against a route,
+ /// possibly handling different contexts via different handlers.
+ ///
+ /// The type of the data used to select a suitable handler
+ /// for the context.
+ ///
+ public abstract class RouteResolverBase : ConfiguredObject
+ {
+ private readonly RouteMatcher _matcher;
+ private readonly List<(TData data, RouteHandlerCallback handler)> _dataHandlerPairs
+ = new List<(TData data, RouteHandlerCallback handler)>();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The route to match URL paths against.
+ protected RouteResolverBase(string route)
+ {
+ _matcher = RouteMatcher.Parse(route, false);
+ }
+
+ ///
+ /// Gets the route this resolver matches URL paths against.
+ ///
+ public string Route => _matcher.Route;
+
+ ///
+ /// Associates some data to a handler.
+ /// The method calls
+ /// to extract data from the context; then, for each registered data / handler pair,
+ /// is called to determine whether
+ /// should be called.
+ ///
+ /// Data used to determine which contexts are
+ /// suitable to be handled by .
+ /// A callback used to handle matching contexts.
+ /// is .
+ ///
+ ///
+ ///
+ ///
+ public void Add(TData data, RouteHandlerCallback handler)
+ {
+ EnsureConfigurationNotLocked();
+
+ handler = Validate.NotNull(nameof(handler), handler);
+ _dataHandlerPairs.Add((data, handler));
+ }
+
+ ///
+ /// Associates some data to a synchronous handler.
+ /// The method calls
+ /// to extract data from the context; then, for each registered data / handler pair,
+ /// is called to determine whether
+ /// should be called.
+ ///
+ /// Data used to determine which contexts are
+ /// suitable to be handled by .
+ /// A callback used to handle matching contexts.
+ /// is .
+ ///
+ ///
+ ///
+ ///
+ public void Add(TData data, SyncRouteHandlerCallback handler)
+ {
+ EnsureConfigurationNotLocked();
+
+ handler = Validate.NotNull(nameof(handler), handler);
+ _dataHandlerPairs.Add((data, (ctx, route) => {
+ handler(ctx, route);
+ return Task.CompletedTask;
+ }));
+ }
+
+ ///
+ /// Locks this instance, preventing further handler additions.
+ ///
+ public void Lock() => LockConfiguration();
+
+ ///
+ /// Asynchronously matches a URL path against ;
+ /// if the match is successful, tries to handle the specified
+ /// using handlers selected according to data extracted from the context.
+ /// Registered data / handler pairs are tried in the same order they were added.
+ ///
+ /// The context to handle.
+ /// A , representing the ongoing operation,
+ /// that will return a result in the form of one of the constants.
+ ///
+ ///
+ ///
+ ///
+ public async Task ResolveAsync(IHttpContext context)
+ {
+ LockConfiguration();
+
+ var match = _matcher.Match(context.RequestedPath);
+ if (match == null)
+ return RouteResolutionResult.RouteNotMatched;
+
+ var contextData = GetContextData(context);
+ var result = RouteResolutionResult.NoHandlerSelected;
+ foreach (var (data, handler) in _dataHandlerPairs)
+ {
+ if (!MatchContextData(contextData, data))
+ continue;
+
+ try
+ {
+ await handler(context, match).ConfigureAwait(false);
+ return RouteResolutionResult.Success;
+ }
+ catch (RequestHandlerPassThroughException)
+ {
+ result = RouteResolutionResult.NoHandlerSuccessful;
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Called by to extract data from a context.
+ /// The extracted data are then used to select which handlers are suitable
+ /// to handle the context.
+ ///
+ /// The HTTP context to extract data from.
+ /// The extracted data.
+ ///
+ ///
+ protected abstract TData GetContextData(IHttpContext context);
+
+ ///
+ /// Called by to match data extracted from a context
+ /// against data associated with a handler.
+ ///
+ /// The data extracted from the context.
+ /// The data associated with the handler.
+ /// if the handler should be called to handle the context;
+ /// otherwise, .
+ protected abstract bool MatchContextData(TData contextData, TData handlerData);
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteResolverCollectionBase`2.cs b/src/EmbedIO/Routing/RouteResolverCollectionBase`2.cs
new file mode 100644
index 000000000..a4abfdb55
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteResolverCollectionBase`2.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using EmbedIO.Internal;
+using EmbedIO.Utilities;
+using Swan.Collections;
+using Swan.Configuration;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Implements the logic for resolving a context and a URL path against a list of routes,
+ /// possibly handling different HTTP methods via different handlers.
+ ///
+ /// The type of the data used to select a suitable handler
+ /// for a context.
+ /// The type of the route resolver.
+ ///
+ public abstract class RouteResolverCollectionBase : ConfiguredObject
+ where TResolver : RouteResolverBase
+ {
+ private readonly List _resolvers = new List();
+
+ ///
+ /// Associates some data and a route to a handler.
+ ///
+ /// Data used to determine which contexts are
+ /// suitable to be handled by .
+ /// The route to match URL paths against.
+ /// A callback used to handle matching contexts.
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ /// is not a valid route.
+ /// The method
+ /// returned .
+ ///
+ ///
+ ///
+ public void Add(TData data, string route, RouteHandlerCallback handler)
+ {
+ handler = Validate.NotNull(nameof(handler), handler);
+ GetResolver(route).Add(data, handler);
+ }
+
+ ///
+ /// Associates some data and a route to a synchronous handler.
+ ///
+ /// Data used to determine which contexts are
+ /// suitable to be handled by .
+ /// The route to match URL paths against.
+ /// A callback used to handle matching contexts.
+ ///
+ /// is .
+ /// - or -
+ /// is .
+ ///
+ /// is not a valid route.
+ /// The method
+ /// returned .
+ ///
+ ///
+ ///
+ public void Add(TData data, string route, SyncRouteHandlerCallback handler)
+ {
+ handler = Validate.NotNull(nameof(handler), handler);
+ GetResolver(route).Add(data, handler);
+ }
+
+ ///
+ /// Asynchronously matches a URL path against ;
+ /// if the match is successful, tries to handle the specified
+ /// using handlers selected according to data extracted from the context.
+ /// Registered resolvers are tried in the same order they were added by calling
+ /// .
+ ///
+ /// The context to handle.
+ /// A , representing the ongoing operation,
+ /// that will return a result in the form of one of the constants.
+ ///
+ public async Task ResolveAsync(IHttpContext context)
+ {
+ var result = RouteResolutionResult.RouteNotMatched;
+ foreach (var resolver in _resolvers)
+ {
+ var resolverResult = await resolver.ResolveAsync(context).ConfigureAwait(false);
+ OnResolverCalled(context, resolver, resolverResult);
+ if (resolverResult == RouteResolutionResult.Success)
+ return RouteResolutionResult.Success;
+
+ // This is why RouteResolutionResult constants must not be reordered.
+ if (resolverResult > result)
+ result = resolverResult;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Locks this collection, preventing further additions.
+ ///
+ public void Lock() => LockConfiguration();
+
+ ///
+ protected override void OnBeforeLockConfiguration()
+ {
+ foreach (var resolver in _resolvers)
+ resolver.Lock();
+ }
+
+ ///
+ /// Called by
+ /// and to create an instance
+ /// of that can resolve the specified route.
+ /// If this method returns , an
+ /// is thrown by the calling method.
+ ///
+ /// The route to resolve.
+ /// A newly-constructed instance of .
+ protected abstract TResolver CreateResolver(string route);
+
+ ///
+ /// Called by when a resolver's
+ /// ResolveAsync method has been called
+ /// to resolve a context.
+ /// This callback method may be used e.g. for logging or testing.
+ ///
+ /// The context to handle.
+ /// The resolver just called.
+ /// The result returned by .ResolveAsync .
+ protected virtual void OnResolverCalled(IHttpContext context, TResolver resolver, RouteResolutionResult result)
+ {
+ }
+
+ private TResolver GetResolver(string route)
+ {
+ var resolver = _resolvers.FirstOrDefault(r => r.Route == route);
+ if (resolver == null)
+ {
+ resolver = CreateResolver(route);
+ SelfCheck.Assert(resolver != null, $"{nameof(CreateResolver)} returned null.");
+
+ _resolvers.Add(resolver);
+ }
+
+ return resolver;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteVerbResolver.cs b/src/EmbedIO/Routing/RouteVerbResolver.cs
new file mode 100644
index 000000000..e83491dda
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteVerbResolver.cs
@@ -0,0 +1,25 @@
+namespace EmbedIO.Routing
+{
+ ///
+ /// Handles a HTTP request by matching it against a route,
+ /// possibly handling different HTTP methods via different handlers.
+ ///
+ public sealed class RouteVerbResolver : RouteResolverBase
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The route to match URL paths against.
+ public RouteVerbResolver(string route)
+ : base(route)
+ {
+ }
+
+ ///
+ protected override HttpVerbs GetContextData(IHttpContext context) => context.Request.HttpVerb;
+
+ ///
+ protected override bool MatchContextData(HttpVerbs contextVerb, HttpVerbs handlerVerb)
+ => handlerVerb == HttpVerbs.Any || contextVerb == handlerVerb;
+ }
+}
\ No newline at end of file
diff --git a/src/EmbedIO/Routing/RouteVerbResolverCollection.cs b/src/EmbedIO/Routing/RouteVerbResolverCollection.cs
new file mode 100644
index 000000000..fafe734bc
--- /dev/null
+++ b/src/EmbedIO/Routing/RouteVerbResolverCollection.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Threading.Tasks;
+using EmbedIO.Utilities;
+using Swan.Logging;
+
+namespace EmbedIO.Routing
+{
+ ///
+ /// Handles a HTTP request by matching it against a list of routes,
+ /// possibly handling different HTTP methods via different handlers.
+ ///
+ ///
+ ///
+ public sealed class RouteVerbResolverCollection : RouteResolverCollectionBase
+ {
+ private readonly string _logSource;
+
+ internal RouteVerbResolverCollection(string logSource)
+ {
+ _logSource = logSource;
+ }
+
+ ///
+ /// Adds handlers, associating them with HTTP method / route pairs by means
+ /// of Route attributes.
+ /// A compatible handler is a static or instance method that takes 2
+ /// parameters having the following types, in order:
+ ///
+ ///