diff --git a/API/Protocol/IRequestTarget.cs b/API/Protocol/IRequestTarget.cs index 983ed531..4adbd27b 100644 --- a/API/Protocol/IRequestTarget.cs +++ b/API/Protocol/IRequestTarget.cs @@ -6,12 +6,14 @@ public interface IRequestTarget PathSegment? Current { get; } bool IsLast { get; } - + bool HasTrailingSlash { get; } void Advance(int segments = 1); - ReadOnlyMemory? Next(int offset); + PathSegment? Next(int offset); + + IRequestTarget CopyAndAppend(ReadOnlyMemory suffix); string AsString(bool decode = true, bool remainingOnly = false); diff --git a/Benchmarks/Benchmarks/Compression/PreCompressedBenchmark.cs b/Benchmarks/Benchmarks/Compression/PreCompressedBenchmark.cs new file mode 100644 index 00000000..4e93a951 --- /dev/null +++ b/Benchmarks/Benchmarks/Compression/PreCompressedBenchmark.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; + +using GenHTTP.Benchmarks.Infrastructure; + +using GenHTTP.Modules.Compression; +using GenHTTP.Modules.Compression.Algorithms; +using GenHTTP.Modules.IO; + +namespace GenHTTP.Benchmarks.Benchmarks.Compression; + +[MemoryDiagnoser] +public class PreCompressedBenchmark +{ + private readonly BenchmarkContext _context = CreateContext(); + + [Benchmark] + public ValueTask BenchmarkPreCompressedStaticFiles() => _context.Execute(); + + private static BenchmarkContext CreateContext() + { + var tree = ResourceTree.FromDirectory("./Resources/"); + + var handler = PreCompressedResources.From(tree) + .Add(new BrotliAlgorithm()) + .Add(CompressedContent.Default()); + + var request = "GET /file.js HTTP/1.1\r\nHost: localhost:8080\r\nAccept-Encoding: br;q=1, gzip;q=0.8\r\n\r\n"; + + return new(request, handler.Build()); + } + +} + diff --git a/Benchmarks/GenHTTP.Benchmarks.csproj b/Benchmarks/GenHTTP.Benchmarks.csproj index 475eb430..dd787cae 100644 --- a/Benchmarks/GenHTTP.Benchmarks.csproj +++ b/Benchmarks/GenHTTP.Benchmarks.csproj @@ -26,9 +26,14 @@ - + + Always - + + + + Always + diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs index a0a054bb..9e114da4 100644 --- a/Benchmarks/Program.cs +++ b/Benchmarks/Program.cs @@ -1,7 +1,7 @@ using BenchmarkDotNet.Running; -using GenHTTP.Benchmarks.Benchmarks.IO; +using GenHTTP.Benchmarks.Benchmarks.Compression; -BenchmarkRunner.Run(); +BenchmarkRunner.Run(); -// await new StaticFileBenchmark().BenchmarkStaticFile(); \ No newline at end of file +// await new PreCompressedBenchmark().BenchmarkPreCompressedStaticFiles(); \ No newline at end of file diff --git a/Benchmarks/Resources/file.js.br b/Benchmarks/Resources/file.js.br new file mode 100644 index 00000000..e6c1acc6 Binary files /dev/null and b/Benchmarks/Resources/file.js.br differ diff --git a/Engine/Internal/Protocol/ClientHandler.cs b/Engine/Internal/Protocol/ClientHandler.cs index 41591ce2..bbeab919 100644 --- a/Engine/Internal/Protocol/ClientHandler.cs +++ b/Engine/Internal/Protocol/ClientHandler.cs @@ -9,7 +9,6 @@ using GenHTTP.Engine.Shared.Types; using Glyph11; using Glyph11.Parser; -using Glyph11.Parser.Hardened; using Glyph11.Parser.UltraHardened; using Glyph11.Protocol; using StringContent = GenHTTP.Modules.IO.Strings.StringContent; diff --git a/Engine/Shared/Types/RequestTarget.cs b/Engine/Shared/Types/RequestTarget.cs index 32f288cb..3a2136c9 100644 --- a/Engine/Shared/Types/RequestTarget.cs +++ b/Engine/Shared/Types/RequestTarget.cs @@ -53,7 +53,7 @@ public void Apply(ReadOnlyMemory path) MoveNext(); } - public ReadOnlyMemory? Next(int offset) + public PathSegment? Next(int offset) { if (Current == null) { @@ -62,7 +62,7 @@ public void Apply(ReadOnlyMemory path) if (offset == 0) { - return Current.Value.Value; + return Current; } var span = _path.Span; @@ -86,7 +86,7 @@ public void Apply(ReadOnlyMemory path) if (skip == offset) { - return idx < 0 ? _path.Slice(start, length - start) : _path.Slice(start, idx); + return new(idx < 0 ? _path.Slice(start, length - start) : _path.Slice(start, idx)); } i += idx < 0 ? (length - start) : idx; @@ -143,6 +143,45 @@ private void MoveNext() } } + public IRequestTarget CopyAndAppend(ReadOnlyMemory suffix) + { + var original = _path; + + var buffer = new byte[original.Length + suffix.Length]; + + original.Span.CopyTo(buffer); + suffix.Span.CopyTo(buffer.AsSpan(original.Length)); + + var combined = new ReadOnlyMemory(buffer); + + var copy = new RequestTarget + { + _path = combined, + _offset = _offset, + _segmentStart = _segmentStart + }; + + if (Current != null) + { + var span = combined.Span; + + var relative = span[_segmentStart..].IndexOf((byte)'/'); + + if (relative < 0) + { + copy._offset = combined.Length; + copy.Current = new PathSegment(combined[_segmentStart..]); + } + else + { + copy._offset = _segmentStart + relative; + copy.Current = new PathSegment(combined.Slice(_segmentStart, relative)); + } + } + + return copy; + } + public string AsString(bool decode = true, bool remainingOnly = false) { ReadOnlyMemory slice; @@ -162,7 +201,7 @@ public string AsString(bool decode = true, bool remainingOnly = false) } var stringPath = Encoding.ASCII.GetString(slice.Span); - + if (decode && stringPath.Contains('%')) { return Uri.UnescapeDataString(stringPath); diff --git a/Modules/Compression/PreCompressedResources.cs b/Modules/Compression/PreCompressedResources.cs new file mode 100644 index 00000000..b36ce42b --- /dev/null +++ b/Modules/Compression/PreCompressedResources.cs @@ -0,0 +1,43 @@ +using GenHTTP.Api.Content.IO; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Modules.Compression.PreCompression; + +namespace GenHTTP.Modules.Compression; + +/// +/// Allows to serve static resources that already are pre-compressed by +/// an external system. +/// +/// +/// Expects compressed files to be stored side-by-side with the non-compressed +/// files (so for example "file.js" and "file.br.js"). +/// +/// By default, no algorithms are configured for a newly created handler, so +/// you need to register them by yourself. As the supported algorithms are +/// controlled by the external system pre-compressing the files, this is +/// more efficient than looping through all compression algorithms supported +/// by GenHTTP. +/// +public static class PreCompressedResources +{ + + /// + /// Creates a new handler that will look for existing, pre-compressed files + /// based on the client preferences and serve them if suitable. + /// + /// The tree to be served + /// The newly created handler builder + public static PreCompressedResourceHandlerBuilder From(IBuilder tree) + => From(tree.Build()); + + /// + /// Creates a new handler that will look for existing, pre-compressed files + /// based on the client preferences and serve them if suitable. + /// + /// The tree to be served + /// The newly created handler builder + public static PreCompressedResourceHandlerBuilder From(IResourceTree tree) + => new(tree); + +} diff --git a/Modules/Compression/PreCompression/PreCompressedResourceHandler.cs b/Modules/Compression/PreCompression/PreCompressedResourceHandler.cs new file mode 100644 index 00000000..6a9fc873 --- /dev/null +++ b/Modules/Compression/PreCompression/PreCompressedResourceHandler.cs @@ -0,0 +1,106 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.IO; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Compression.Providers; +using GenHTTP.Modules.IO; +using GenHTTP.Modules.IO.Streaming; + +namespace GenHTTP.Modules.Compression.PreCompression; + +public sealed class PreCompressedResourceHandler : IHandler +{ + private readonly IResourceTree _tree; + + private readonly IHandler _regular; + + private readonly List _algorithms; + + public PreCompressedResourceHandler(IResourceTree tree, List algorithms, char separator) + { + _tree = tree; + + _regular = Resources.From(tree).Build(); + + _algorithms = algorithms.Select(a => + { + var extension = new byte[a.Name.Value.Length + 1]; + extension[0] = (byte)separator; + a.Name.Value.Span.CopyTo(extension.AsSpan(1)); + + return new SupportedCompression(a, extension); + }) + .OrderByDescending(a => (int)a.Algorithm.Priority) + .ToList(); + } + + public ValueTask PrepareAsync() => _regular.PrepareAsync(); + + public async ValueTask HandleAsync(IRequest request) + { + var target = request.Header.Target; + + var file = GetFileName(target); + + if (file is null) + { + return null; + } + + var acceptEncodingHeader = request.Header.Headers.GetEntry(KnownHeaders.AcceptEncoding); + + if (acceptEncodingHeader != null) + { + var requested = AcceptEncodingHeader.ParseSupported(acceptEncodingHeader.Value.Span); + + foreach (var supported in _algorithms) + { + if (requested.Contains(supported.Algorithm.Name)) + { + var newTarget = target.CopyAndAppend(supported.Extension); + + var (_, resource) = await _tree.FindAsync(newTarget); + + if (resource is not null) + { + var contentType = file.GuessContentType() ?? ContentType.ApplicationOctetStream; + var content = new ResourceContent(resource, contentType, supported.Algorithm.Name.Value); + + return request.Respond() + .Content(content) + .Build(); + } + } + } + } + + return await _regular.HandleAsync(request); + } + + private static string? GetFileName(IRequestTarget target) + { + if (target.HasTrailingSlash) + { + return null; + } + + var offset = 0; + + PathSegment? current = null; + + while (true) + { + var next = target.Next(offset++); + + if (next == null) + { + break; + } + + current = next.Value; + } + + return current?.Decode(); + } + +} diff --git a/Modules/Compression/PreCompression/PreCompressedResourceHandlerBuilder.cs b/Modules/Compression/PreCompression/PreCompressedResourceHandlerBuilder.cs new file mode 100644 index 00000000..f0729742 --- /dev/null +++ b/Modules/Compression/PreCompression/PreCompressedResourceHandlerBuilder.cs @@ -0,0 +1,56 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.IO; + +namespace GenHTTP.Modules.Compression.PreCompression; + +public sealed class PreCompressedResourceHandlerBuilder : IHandlerBuilder +{ + private readonly List _concerns = []; + + private readonly List _algorithms = []; + + private readonly IResourceTree _source; + + private char _separator = '.'; + + public PreCompressedResourceHandlerBuilder(IResourceTree source) + { + _source = source; + } + + /// + /// Sets the separator used to build the paths to the compressed files. Defaults to '.'. + /// + /// The separator used to built file paths + /// + /// If your files are stored like "file.js+br", you can set this to '+'. + /// + public PreCompressedResourceHandlerBuilder Separator(char separator) + { + _separator = separator; + return this; + } + + /// + /// Registers a algorithm used to search for file names. + /// + /// The algorithm to use for searching + /// + /// The handler will not use the compression or decompression features of the algorithm, + /// but use the priority field to prefer algorithms with better compression ratios. + /// + public PreCompressedResourceHandlerBuilder Add(ICompressionAlgorithm algorithm) + { + _algorithms.Add(algorithm); + return this; + } + + public PreCompressedResourceHandlerBuilder Add(IConcernBuilder concern) + { + _concerns.Add(concern); + return this; + } + + public IHandler Build() => Concerns.Chain(_concerns, new PreCompressedResourceHandler(_source, _algorithms, _separator)); + +} diff --git a/Modules/Compression/PreCompression/SupportedCompression.cs b/Modules/Compression/PreCompression/SupportedCompression.cs new file mode 100644 index 00000000..a113d5a6 --- /dev/null +++ b/Modules/Compression/PreCompression/SupportedCompression.cs @@ -0,0 +1,12 @@ +using GenHTTP.Api.Content.IO; + +namespace GenHTTP.Modules.Compression.PreCompression; + +public readonly struct SupportedCompression(ICompressionAlgorithm algorithm, ReadOnlyMemory extension) +{ + + public ICompressionAlgorithm Algorithm => algorithm; + + public ReadOnlyMemory Extension => extension; + +} diff --git a/Modules/Compression/Providers/AcceptEncodingHeader.cs b/Modules/Compression/Providers/AcceptEncodingHeader.cs new file mode 100644 index 00000000..64160444 --- /dev/null +++ b/Modules/Compression/Providers/AcceptEncodingHeader.cs @@ -0,0 +1,55 @@ +using GenHTTP.Api.Content.IO; + +namespace GenHTTP.Modules.Compression.Providers; + +public static class AcceptEncodingHeader +{ + + public static HashSet ParseSupported(ReadOnlySpan acceptHeader) + { + var result = new HashSet(); + var start = 0; + + while (start < acceptHeader.Length) + { + var comma = acceptHeader[start..].IndexOf((byte)','); + var end = comma >= 0 ? start + comma : acceptHeader.Length; + + var token = acceptHeader.Slice(start, end - start); + + var semicolon = token.IndexOf((byte)';'); + var nameSpan = semicolon >= 0 ? token[..semicolon] : token; + + var part = TrimAscii(nameSpan); + + if (!part.IsEmpty) + { + result.Add(new(part.ToArray())); + } + + start = end + 1; + } + + return result; + } + + private static ReadOnlySpan TrimAscii(ReadOnlySpan span) + { + var start = 0; + var end = span.Length - 1; + + while (start <= end && IsAsciiWhiteSpace(span[start])) + start++; + + while (end >= start && IsAsciiWhiteSpace(span[end])) + end--; + + return span.Slice(start, end - start + 1); + } + + private static bool IsAsciiWhiteSpace(byte b) + { + return b == (byte)' ' || b == (byte)'\t'; + } + +} diff --git a/Modules/Compression/Providers/CompressionConcern.cs b/Modules/Compression/Providers/CompressionConcern.cs index eb25fd86..5042266c 100644 --- a/Modules/Compression/Providers/CompressionConcern.cs +++ b/Modules/Compression/Providers/CompressionConcern.cs @@ -1,5 +1,4 @@ using System.IO.Compression; - using GenHTTP.Api.Content; using GenHTTP.Api.Content.IO; using GenHTTP.Api.Protocol; @@ -34,7 +33,7 @@ public sealed class CompressionConcern : IConcern public IHandler Content { get; } - private IReadOnlyDictionary Algorithms { get; } + private List Algorithms { get; } private CompressionLevel Level { get; } @@ -44,13 +43,15 @@ public sealed class CompressionConcern : IConcern #region Initialization - public CompressionConcern(IHandler content, IReadOnlyDictionary algorithms, CompressionLevel level, ulong? minimumSize) + public CompressionConcern(IHandler content, List algorithms, CompressionLevel level, ulong? minimumSize) { Content = content; Algorithms = algorithms; Level = level; MinimumSize = minimumSize; + + Algorithms.Sort((a, b) => b.Priority.CompareTo(a.Priority)); } #endregion @@ -60,7 +61,7 @@ public CompressionConcern(IHandler content, IReadOnlyDictionary HandleAsync(IRequest request) { var acceptEncoding = request.Header.Headers.GetEntry(KnownHeaders.AcceptEncoding); - + var response = await Content.HandleAsync(request); if (response == null) @@ -76,46 +77,44 @@ public CompressionConcern(IHandler content, IReadOnlyDictionary (int)a.Priority)) + foreach (var algorithm in Algorithms) { - if (supported.Contains(algorithm.Name)) + if (!supported.Contains(algorithm.Name)) { - var builder = response.Rebuild(); + continue; + } - builder.Content(algorithm.Compress(content, Level)); + var builder = response.Rebuild(); - var vary = response.Headers.GetEntry(KnownHeaders.Vary); + builder.Content(algorithm.Compress(content, Level)); - if (vary != null) - { - var combined = new byte[vary.Value.Length + KnownHeaders.AcceptEncoding.Length + 2]; + var vary = response.Headers.GetEntry(KnownHeaders.Vary); - var span = combined.AsSpan(); - var offset = 0; + if (vary != null) + { + var combined = new byte[vary.Value.Length + KnownHeaders.AcceptEncoding.Length + 2]; - vary.Value.Span.CopyTo(span); - offset += vary.Value.Length; + var span = combined.AsSpan(); + var offset = 0; - span[offset++] = (byte)','; - span[offset++] = (byte)' '; + vary.Value.Span.CopyTo(span); + offset += vary.Value.Length; - KnownHeaders.AcceptEncoding.Span.CopyTo(span[offset..]); + span[offset++] = (byte)','; + span[offset++] = (byte)' '; - builder.Header(KnownHeaders.Vary, combined); - } - else - { - builder.Header(KnownHeaders.Vary, KnownHeaders.AcceptEncoding); - } + KnownHeaders.AcceptEncoding.Span.CopyTo(span[offset..]); - return builder.Build(); + builder.Header(KnownHeaders.Vary, combined); + } + else + { + builder.Header(KnownHeaders.Vary, KnownHeaders.AcceptEncoding); } + + return builder.Build(); } } } @@ -129,7 +128,7 @@ private static bool ShouldCompressByType(ContentType? type) if (type is not null) { var withoutOptions = type.Value.WithoutOptions(); - + if (CompressibleTypes.Contains(withoutOptions)) { return true; @@ -145,53 +144,6 @@ private bool ShouldCompressBySize(IResponse response) return MinimumSize is null || contentLength is null || contentLength >= MinimumSize; } - - private static HashSet ParseSupported(ReadOnlySpan acceptHeader) - { - var result = new HashSet(); - var start = 0; - - while (start < acceptHeader.Length) - { - var comma = acceptHeader[start..].IndexOf((byte)','); - var end = comma >= 0 ? start + comma : acceptHeader.Length; - - var token = acceptHeader.Slice(start, end - start); - - var semicolon = token.IndexOf((byte)';'); - var nameSpan = semicolon >= 0 ? token[..semicolon] : token; - - var part = TrimAscii(nameSpan); - - if (!part.IsEmpty) - { - result.Add(new(part.ToArray())); - } - - start = end + 1; - } - - return result; - } - - private static ReadOnlySpan TrimAscii(ReadOnlySpan span) - { - var start = 0; - var end = span.Length - 1; - - while (start <= end && IsAsciiWhiteSpace(span[start])) - start++; - - while (end >= start && IsAsciiWhiteSpace(span[end])) - end--; - - return span.Slice(start, end - start + 1); - } - - private static bool IsAsciiWhiteSpace(byte b) - { - return b == (byte)' ' || b == (byte)'\t'; - } public ValueTask PrepareAsync() => Content.PrepareAsync(); diff --git a/Modules/Compression/Providers/CompressionConcernBuilder.cs b/Modules/Compression/Providers/CompressionConcernBuilder.cs index ca5e5de3..48e43b2d 100644 --- a/Modules/Compression/Providers/CompressionConcernBuilder.cs +++ b/Modules/Compression/Providers/CompressionConcernBuilder.cs @@ -42,12 +42,7 @@ public CompressionConcernBuilder MinimumSize(ulong? minimumSize) return this; } - public IConcern Build(IHandler content) - { - var algorithms = _algorithms.ToDictionary(a => a.Name); - - return new CompressionConcern(content, algorithms, _level, _minimumSize); - } + public IConcern Build(IHandler content) => new CompressionConcern(content, _algorithms, _level, _minimumSize); #endregion diff --git a/Modules/DirectoryBrowsing/Provider/ListingRouter.cs b/Modules/DirectoryBrowsing/Provider/ListingRouter.cs index 161ae1cf..07462f45 100644 --- a/Modules/DirectoryBrowsing/Provider/ListingRouter.cs +++ b/Modules/DirectoryBrowsing/Provider/ListingRouter.cs @@ -28,7 +28,7 @@ public ListingRouter(IResourceTree tree) public async ValueTask HandleAsync(IRequest request) { - var (node, resource) = await Tree.Find(request.Header.Target); + var (node, resource) = await Tree.FindAsync(request.Header.Target); if (resource is not null) { diff --git a/Modules/IO/FileSystem/DirectoryContainer.cs b/Modules/IO/FileSystem/DirectoryContainer.cs index 05be8041..976adae0 100644 --- a/Modules/IO/FileSystem/DirectoryContainer.cs +++ b/Modules/IO/FileSystem/DirectoryContainer.cs @@ -1,13 +1,10 @@ -using System.Collections.Concurrent; - -using GenHTTP.Api.Content.IO; +using GenHTTP.Api.Content.IO; using GenHTTP.Api.Protocol; namespace GenHTTP.Modules.IO.FileSystem; internal class DirectoryContainer : IResourceContainer { - private readonly ConcurrentDictionary _resourceCache = new(); #region Initialization @@ -68,12 +65,15 @@ public ValueTask> GetResources() public ValueTask TryGetResourceAsync(PathSegment segment) { - return new ValueTask(_resourceCache.GetOrAdd(segment, static (key, dir) => + var path = Path.Combine(Directory.FullName, segment.Decode()); + var file = new FileInfo(path); + + if (File.Exists(file.FullName)) { - var path = Path.Combine(dir.FullName, key.Decode()); - var file = new FileInfo(path); - return file.Exists ? Resource.FromFile(file).Build() : null; - }, Directory)); + return new(Resource.FromFile(file).Build()); + } + + return default; } #endregion diff --git a/Modules/IO/Providers/ResourceHandler.cs b/Modules/IO/Providers/ResourceHandler.cs index f08ace9d..4d2f3eec 100644 --- a/Modules/IO/Providers/ResourceHandler.cs +++ b/Modules/IO/Providers/ResourceHandler.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; - -using GenHTTP.Api.Content; +using GenHTTP.Api.Content; using GenHTTP.Api.Content.IO; using GenHTTP.Api.Protocol; @@ -10,8 +8,7 @@ namespace GenHTTP.Modules.IO.Providers; public sealed class ResourceHandler : IHandler { - private readonly ConcurrentDictionary _contentCache = new(); - + #region Get-/Setters private IResourceTree Tree { get; } @@ -33,14 +30,12 @@ public ResourceHandler(IResourceTree tree) public async ValueTask HandleAsync(IRequest request) { - var (_, resource) = await Tree.Find(request.Header.Target); + var (_, resource) = await Tree.FindAsync(request.Header.Target); if (resource is not null) { - var content = _contentCache.GetOrAdd(resource, static r => new ResourceContent(r)); - return request.Respond() - .Content(content) + .Content(new ResourceContent(resource)) .Build(); } diff --git a/Modules/IO/ResourceTreeExtensions.cs b/Modules/IO/ResourceTreeExtensions.cs index 6180fa42..95d79b06 100644 --- a/Modules/IO/ResourceTreeExtensions.cs +++ b/Modules/IO/ResourceTreeExtensions.cs @@ -13,7 +13,7 @@ public static class ResourceTreeExtensions /// The node used to resolve the target /// The target to be resolved /// A tuple of the node and resource resolved from the container (or both null, if they could not be resolved) - public static async ValueTask<(IResourceContainer? node, IResource? resource)> Find(this IResourceContainer node, IRequestTarget target) + public static async ValueTask<(IResourceContainer? node, IResource? resource)> FindAsync(this IResourceContainer node, IRequestTarget target) { var current = target.Current; @@ -40,7 +40,7 @@ public static class ResourceTreeExtensions if ((childNode = await node.TryGetNodeAsync(current.Value)) != null) { target.Advance(); - return await childNode.Find(target); + return await childNode.FindAsync(target); } } diff --git a/Modules/IO/Streaming/ResourceContent.cs b/Modules/IO/Streaming/ResourceContent.cs index 1f80e162..a5d755a4 100644 --- a/Modules/IO/Streaming/ResourceContent.cs +++ b/Modules/IO/Streaming/ResourceContent.cs @@ -5,7 +5,7 @@ namespace GenHTTP.Modules.IO.Streaming; public sealed class ResourceContent : IResponseContent { - + #region Get-/Setters public ulong? Length => Resource.Length; @@ -14,16 +14,17 @@ public sealed class ResourceContent : IResponseContent private IResource Resource { get; } - public ReadOnlyMemory? Encoding => null; + public ReadOnlyMemory? Encoding { get; } #endregion - + #region Initialization - public ResourceContent(IResource resource, ContentType? contentType = null) + public ResourceContent(IResource resource, ContentType? contentType = null, ReadOnlyMemory? encoding = null) { Resource = resource; Type = contentType ?? resource.GuessContentType(); + Encoding = encoding; } #endregion diff --git a/Modules/Reflection/Routing/Segments/RegexSegment.cs b/Modules/Reflection/Routing/Segments/RegexSegment.cs index 67bc19bd..cfde66ab 100644 --- a/Modules/Reflection/Routing/Segments/RegexSegment.cs +++ b/Modules/Reflection/Routing/Segments/RegexSegment.cs @@ -56,7 +56,7 @@ public RegexSegment(string definition) return (false, 0); } - var stringPart = Encoding.ASCII.GetString(part.Value.Span); + var stringPart = part.Value.ToString(); var match = _matcher.Match(stringPart); diff --git a/Modules/Reflection/Routing/Segments/SimpleVariableSegment.cs b/Modules/Reflection/Routing/Segments/SimpleVariableSegment.cs index aee99a37..97a7cd34 100644 --- a/Modules/Reflection/Routing/Segments/SimpleVariableSegment.cs +++ b/Modules/Reflection/Routing/Segments/SimpleVariableSegment.cs @@ -1,4 +1,3 @@ -using System.Text; using GenHTTP.Api.Protocol; namespace GenHTTP.Modules.Reflection.Routing.Segments; @@ -25,7 +24,7 @@ internal sealed class SimpleVariableSegment(string variableName) : IRoutingSegme return (false, 0); } - argumentSink.Add(variableName, Encoding.ASCII.GetString(part.Value.Span)); // todo: optimize? + argumentSink.Add(variableName, part.Value.ToString()); // todo: optimize? return (true, 1); } diff --git a/Modules/Reflection/Routing/Segments/StringSegment.cs b/Modules/Reflection/Routing/Segments/StringSegment.cs index a181a966..24c3790f 100644 --- a/Modules/Reflection/Routing/Segments/StringSegment.cs +++ b/Modules/Reflection/Routing/Segments/StringSegment.cs @@ -17,7 +17,7 @@ internal sealed class StringSegment(string segment) : IRoutingSegment { var next = target.Next(offset); - if (next?.Span.SequenceEqual(_segmentBytes.Span) ?? false) + if (next?.Value.Span.SequenceEqual(_segmentBytes.Span) ?? false) { return (true, 1); } diff --git a/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs b/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs index 7b22aab0..3dafc5bd 100644 --- a/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs +++ b/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs @@ -38,7 +38,7 @@ public StaticWebsiteHandler(IResourceTree tree) if (target.HasTrailingSlash) { - var (node, _) = await Tree.Find(target); + var (node, _) = await Tree.FindAsync(target); if (node != null) { diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj index 7fe2faa7..dcb9f250 100644 --- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj +++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj @@ -31,6 +31,9 @@ + + + diff --git a/Testing/Acceptance/Modules/Archives/FunctionalTests.cs b/Testing/Acceptance/Modules/Archives/FunctionalTests.cs index 0f9426ce..fef512e1 100644 --- a/Testing/Acceptance/Modules/Archives/FunctionalTests.cs +++ b/Testing/Acceptance/Modules/Archives/FunctionalTests.cs @@ -30,7 +30,7 @@ public async Task TestFormats() var target = new RequestTarget(); target.Apply("/SubDir/SubDir/SubFile.txt"u8.ToArray()); - var (foundNode, foundFile) = await tree.Find(target); + var (foundNode, foundFile) = await tree.FindAsync(target); Assert.IsNotNull(foundNode); Assert.IsNotNull(foundFile); @@ -54,7 +54,7 @@ public async Task TestNonSeekableResource() var target = new RequestTarget(); target.Apply("/SubDir/SubDir/SubFile.txt"u8.ToArray()); - var (_, foundFile) = await tree.Find(target); + var (_, foundFile) = await tree.FindAsync(target); Assert.AreEqual("3", await GetContentAsync(foundFile!)); } diff --git a/Testing/Acceptance/Modules/Compression/PreCompressionTests.cs b/Testing/Acceptance/Modules/Compression/PreCompressionTests.cs new file mode 100644 index 00000000..7525f019 --- /dev/null +++ b/Testing/Acceptance/Modules/Compression/PreCompressionTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Headers; + +using GenHTTP.Modules.Compression; +using GenHTTP.Modules.Compression.Algorithms; +using GenHTTP.Modules.IO; + +using GenHTTP.Testing.Acceptance.Utilities; + +namespace GenHTTP.Testing.Acceptance.Modules.Compression; + +[TestClass] +public class PreCompressionTests +{ + + [TestMethod] + [MultiEngineTest] + public async Task TestRegular(TestEngine engine) + { + await using var runner = await RunAsync(engine); + + var request = runner.GetRequest("/Resources/LargeTemplate.html"); + + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + + using var response = await runner.GetResponseAsync(request); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("br", response.Content.Headers.ContentEncoding.First()); + + Assert.AreEqual("text/html", response.Content.Headers.ContentType?.ToString()); + + var content = await response.GetContentAsync(); + + Assert.DoesNotContain("precompressed", content); // not automatically decompressed + } + + [TestMethod] + [MultiEngineTest] + public async Task TestNonSupportedAlgorithm(TestEngine engine) + { + await using var runner = await RunAsync(engine); + + var request = runner.GetRequest("/Resources/LargeTemplate.html"); + + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + + using var response = await runner.GetResponseAsync(request); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.IsEmpty(response.Content.Headers.ContentEncoding); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestNoCompressionRequested(TestEngine engine) + { + await using var runner = await RunAsync(engine); + + using var response = await runner.GetResponseAsync("/Resources/LargeTemplate.html"); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.IsEmpty(response.Content.Headers.ContentEncoding); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestFolderNotFound(TestEngine engine) + { + await using var runner = await RunAsync(engine); + + var request = runner.GetRequest("/Resources/SubDirectory/"); + + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + + using var response = await runner.GetResponseAsync(request); + + await response.AssertStatusAsync(HttpStatusCode.NotFound); + } + + [TestMethod] + public void TestChaining() + { + var handler = PreCompressedResources.From(ResourceTree.FromAssembly()) + .Add(new BrotliAlgorithm()); + + Chain.Works(handler); + } + + private static async ValueTask RunAsync(TestEngine engine) + { + var handler = PreCompressedResources.From(ResourceTree.FromAssembly()) + .Add(new BrotliAlgorithm()) + .Separator('+'); + + return await TestHost.RunAsync(handler, defaults: false, engine: engine); + } + +} diff --git a/Testing/Acceptance/Modules/IO/ResourceTreeTests.cs b/Testing/Acceptance/Modules/IO/ResourceTreeTests.cs index 51e050fd..c671a161 100644 --- a/Testing/Acceptance/Modules/IO/ResourceTreeTests.cs +++ b/Testing/Acceptance/Modules/IO/ResourceTreeTests.cs @@ -19,7 +19,7 @@ public async Task TestAssemblyByName() Assert.HasCount(2, await tree.GetNodes()); - Assert.HasCount(6, await tree.GetResources()); + Assert.HasCount(7, await tree.GetResources()); } [TestMethod] diff --git a/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs b/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs index 9baf6e8a..b4f97853 100644 --- a/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs +++ b/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs @@ -24,7 +24,7 @@ public async Task TestNestedTree() .Add("r", tree) .Build(); - var (node, file) = await virt.Find(GetTarget("/r/File.txt")); + var (node, file) = await virt.FindAsync(GetTarget("/r/File.txt")); Assert.IsNotNull(node); Assert.IsNotNull(file); @@ -41,7 +41,7 @@ public async Task TestResource() .Add("res.txt", Resource.FromString("Blubb")) .Build(); - var (node, file) = await virt.Find(GetTarget("/res.txt")); + var (node, file) = await virt.FindAsync(GetTarget("/res.txt")); Assert.IsNotNull(node); Assert.IsNotNull(file); diff --git a/Testing/Acceptance/Modules/Websockets/ProviderTests.cs b/Testing/Acceptance/Modules/Websockets/ProviderTests.cs index b2bca600..67bcd7d9 100644 --- a/Testing/Acceptance/Modules/Websockets/ProviderTests.cs +++ b/Testing/Acceptance/Modules/Websockets/ProviderTests.cs @@ -1,7 +1,5 @@ -using System.Buffers; using System.Net; using GenHTTP.Api.Protocol; -using GenHTTP.Engine.Internal.Protocol; using GenHTTP.Modules.IO; using GenHTTP.Modules.IO.Streaming; using GenHTTP.Modules.Websockets.Provider; diff --git a/Testing/Acceptance/Resources/LargeTemplate.html+br b/Testing/Acceptance/Resources/LargeTemplate.html+br new file mode 100644 index 00000000..bff4cf05 Binary files /dev/null and b/Testing/Acceptance/Resources/LargeTemplate.html+br differ