From b26ce9b98c40f2cc9f5056a95102d92bb2fb97e2 Mon Sep 17 00:00:00 2001 From: Geovanni Perez <1775792+geoperez@users.noreply.github.com> Date: Mon, 19 Aug 2019 10:50:16 -0500 Subject: [PATCH] EmbedIO v3.0 (#344) * Improve XML docs. * [ADD][BRK] FileSystemProvider may be immutable or not. * Revert "[ADD][BRK] FileSystemProvider may be immutable or not." This reverts commit 88a7d51612ae1a615e051d9c7a9af5cd3d01083c. The wrong file was committed. * [ADD][BRK] Method IFileProvider.Start. * [ADD][BRK] Support for mutable directories in FileSystemProvider. * [ADD] Property MimeTypes.Default. * [FIX] FileSystemProvider does not always return a MIME type for files. * [ADD] Class ResourceFileProvider, to replace the functionality of ResourceFilesModule. * [ADD] New ZipFileProvider * Remove unused parameter. * Minor adjustment to ZipFileProvider * Assorted minor fixes / cleanup. * [ADD] Class CompressionStream. * [ADD][BRK] Refactored file server. * Various fixes to FileModule. * [ADD] ZipFileProvider extension methods * Code Review and fix build issues with Unit test * Fix issue with ContentType in Files * [FIX] The default for FileModule.DirectoryListener should be null. * [FIX][ADD][BRK] Improve ZipFileProvider, as follows: * Give an option in the constructor to leave the underlying stream open. * Implement IDisposable correctly. * Improve XML docs. * [ADD] Proeprty IHttpResponse.SendChunked * [FIX][BRK][ENH] Improve FileModule performance. * Generate ETags from file date and length instead of using MD5. * Only one class for mapped resource information. * Handle conditional requests and partial requests in a more RFC-compliant way. * Change IMimeTypeProvider.TryGetMimeType to GetMimeType, returning null on not found. * Fix tests. * [ADD][BRK] Property IHttpContext.Age. * Improve logging of request processing. * Add necessary using blocks in sample program. * Add the possibility to not start the browser automatically in sample program. * Add logging of file cache purge. * Flush the response stream before closing a HTTP context. * Add the possibility of disabling file cache in sample program. * [BRK] Remove useless property IFileProvider.CanSeekFiles. * Use LINQ in MimeTypeProviderStack.GetMimeType. * [ADD] Move some functionality from FileModule.OnRequestAsync to extension methods. * [FIX] Correct file cache purge logging. * Improve code structure in FileModule. * [FIX] FileModule: client seems to hang for 15 seconds if response is being cached. Turns out it was sending out an incorrect Content-Length, thus the client was simply waiting for data that wasn't there. * Use chunked encoding in FileModule if not caching. * [FIX] FileModule: no default document applied if a URL path does not map to a directory. * [FIX] missing session data in unit test * Bump version * Update AppVeyor * Unit test ResourceFileProvider without a FileModule and a web server. * [FIX] Ambiguous call when creating a HttpRedirectException with default status code. * Improve coverage of ResourceFileProvider. * Add methods to get resources, and byte ranges in resources, as byte arrays. * Add class MockFileProvider to test FileModule without an actual file provider. * [BRK] Make DLL name consistent with project name and NuGet package. * [FIX] Context ID is always empty in Mono. * [BRK] Remove unused property IHttpRequest.RequestTraceIdentifier. Its only use was as a unique ID for a HTTP context. Except on Mono, where RequestTraceIdentifier is always Guid.Empty. * Make RouteVerbResolverCollection less log-noisy. * Improve logging of served requests, especially when Debug messages are disabled. * Include LICENSE file in package instead of relying on deprecated PackageLicenseUrl. * Update appveyor.yml * [FIX] Issue wit ubuntu test * [FIX] Issue wit ubuntu test * Add new assembly EmbedIO.Testing * Review * Improve some comments. * Update README.md * Rename class SystemHttpContext to TestHttpContext in Testing library. * [FIX] Wrong resource prefix in Resource class. * [BRK] Rename MimeTypes to MimeType; strip Type suffix from relevant propetty names. * Split MimeType.Association to its own source file. * Improve XML docs. * Add internal method to check a Rfc2616 token without throwing. * [ADD] Methods for validation of MIME types and media ranges. * [ADD] Support for compression preference by MIME type or media range. * Remove unused internal class FileCacheItemExtensions. * [ADD] Method FileModule.ClearCache. * [ADD] More standard request handlers. * Rename RegisterControllerInternal to RegisterControllerTypeCore. The new name is more consistent with the purpose of the method. * [FIX] Potential problem if a controller factory returns a derived type overriding Dispose. * Remove non-abstract constraint when using factory methods for Web API controllers. A factory method may instantiate a concrete subclass. * Rename TestFixture to EndToEndTestFixture. * [FIX] Resource class has incorrect manifest resource prefix. * Rename class Resource to StockResource, more suitable now that it is public. * [FIX] ResourceFileProviderTest still references resources in EmbedIO.Tests assembly. * Add access modifier. * [ADD] Method MimeType.StripParameters to use before validating a MIME type. * [FIX] Error 500 if response content type has parameters, e.g. "text/html; encoding=UTF-8". * [FIX] MimeType.StripParameters does not strip optional white space. * Preview2 * Add Xmldoc to Testing * [FIX] ObjectDisposedException when cancelling a web server under Mono. * [FIX] Improper handling of locks in WebSocketModule. * Use a ConcurrentDictionary to manage contexts in WebSocketModule. * [ADD] Content encoding negotiation as extension method on QValueList. * Add unit tests for content encoding negotiation. * [FIX] Incorrect content encoding resulting from negotiation (issue #310) * Add Slack badge in README. * Code Style * Add "parameterless" and "dereferenced" to ReSpeller's dictionary. * [BRK] Web API controllers do not need constructor parameters any longer. * [ADD] Request data attributes for Web API controllers. * Set ContentEncoding in extension methods that send strings as responses. * [BRK] HttpException.SendResponseAsync should take a CancellationToken. * [ADD][BRK] Most HTTP exceptions can have a plain-text message attached. * Rename source file of generic type. * Add missing XML documentation. * [ADD] Non-generic request data attributes for web API controllers. * [BRK] Make class FormDataAttribute sealed. * Add missing XML documentation. * [[FIX][BRK] Parsed form data should be a read-only dictionary. * Avoid annoying warning about Hungarian notation. * Make GetRequestFormDataAsync remember a previous result for the same context. * [BRK] Remove class RequestParser as it just duplicates functionality. * [FIX] Extension methods should throw NRE when @this is null. * [ADD] Method NameValueCollectionExtensions.ContainsKey. * [ADD] Class LockableNameValueCollection: a NVC that can be made read-only. * [ADD] Utility methods to convert NVCs to dictionaries. * [BRK] Form data is now a read-only NameValueCollection. * [ADD] FormFieldAttribute to receive form field values in Web API controllers. * Make all properties of WebApiController public, so they can be used by attributes. Request data attributes may benefit from access to Request, User, etc. Given the short lifetime and restricted context of controllers, plus the fact that the remaining protected properties were just shortcuts for properties of HttpContext, the advantages of exposing them far outweigh encapsulation concerns. * Remove superfluous using directive. * [ADD] Web API controller methods can also return void or Task. * Rename FormDataParser to UrlEncodedDataParser; make it parse URL queries too. * [FIX] A form field may have an empty name. * [ADD] Support for data extraction from URL queries, similarly to forms. * Add unit tests for QueryData and QueryField attributes. * Remove unused InternalsVisibleTo attribute. * Expose class CookieCollection, renamed to CookieList to avoid conflicts. * Make class PeriodicTask sealed. * Eliminate some annoying warnings. * [ADD] HttpDate class to parse and format dates and times according to RFCs. * [FIX] Incorrect DOW in Expires header. * Minor aesthetic change. * Improve UrlEncodedDataParser and expose it in Utilities. * [ADD][BRK] Support for HttpClient in TestWebServer. * [ADD][BRK] Improve TestWebServer and fix tests. * Remove unnecessary code regions. * [BRK] Make StockResource.Prefix private, as it does not need to be exposed. * [FIX] HTML5 does not need namespaces; even less if they are XHTML namespaces. * Improve XML docs for MimeType. * Add missing XML documentation in EmbedIO.Testing. * Start work on unit tests for Utilities classes. * Remove optional TestFixture attributes. * More unit tests for Utilities classes. * Fix ambiguous reference in XML documentation. * Fix erroneous statement in XML documentation. * [FIX] DataDictionary<,>.GetOrAdd should check for an existing value first. * [FIX] ConcurrentDataDictionary<,>.GetOrAdd should check for an existing value first. * [FIX] Non-null key is not always enforced in DataDictionary<,>. * [FIX] DataDictionary<,>.TryAdd adds null values to dictionary. * Fix wrong class name in XML documentation. * Add unit tests for DataDictionary<,>. * Minor typo * Remove Useless property' * Remove commented code for WebSocket Extensions' * Code Style' * Reorder using directives. * Fix XML documentation for UrlEncodedDataParser.Parse. * [BRK] Remove WebApiModuleBase.OnParameterConversionErrorAsync callback. It made sense when there was no HttpException. Now it just makes debugging harder. * Improve InvalidCastException messages when using non-generic data request attrbutes. * Add some contextual info to 400 Bad Request responses. * Initialize early and cache endpoints (tentative fix for #315). * Update SWAN and Unit test nugets * Update README.md * Update README.md WIP * [ADD][BRK] Improve exception handling in web servers, as follows: * [ADD] IHttpException interface for exceptions that generate their own HTTP status codes. * [ADD] HttpExceptionHandler delegate, to handle exceptions that implement IHttpException. * [ADD] HttpExceptionHandler static class with standard handlers for HTTP exceptions. * [ADD] IWebModule.OnHttpException and IWebServer.OnHttpException properties. * [FIX][BRK] HttpException should not handle response generation by itself; instead, HTTP exceptions should only act on response code and headers, leaving response content rendering to handler callbacks set at a server and/or module level (similarly to what happens with unhandled exceptions). * [FIX][BRK] Parameter path of ExceptionHandlerCallback delegate is redundant (always set to context.Request.Url.AbsolutePath). * [ADD] Fluent extension methods, on both Web modules and servers, to set handlers for HTTP exceptions and for unhandled (non-HTTP) exceptions. * [ADD] Public constants for the header names used by the ExceptionHandler.EmptyResponseWithHeaders method. * Add unit tests for HTTP exception handling. * Add unit tests for unhandled exception handling. * [ADD] FormDataAttribute and QueryDataAttribute can work on non-string parameters. * Remove redundant using directives. * [FIX] Synchronous test method declared async. * [FIX] Missing new keyword on overrides. * ReSharper-assisted code review. * [BRK] Remove redundant SyncRoot property in CookieList. Breaking change because of binary incompatibility. * [FIX][BRK] QValueList.FindPreferredIndex can take an IEnumerable. * Update README.md * Update README.md * Update README.md * Use a HTTP exception for the ultimate "not found" response. * Update README.md * Update toc.yml * Apply better unit testing criteria. * [FIX] DataDictionary<,> behaves inconsistently when cast as ICollection<>. * Add unit tests for ConcurrentDataDictionary<,>. * [FIX] ConcurrentDataDictionary<,>.TryAdd should always simulate adding a null value. * This commit needs to be reviewed * [FIX] LocalSessionManager gets confused when using CookiePath. * [FIX] Minor compiler warning * [FIX] Resolves issue with encode string parameters * [FIX] The concept of "critical exception" is a little too broad. * [ADD][BRK] FromString static class to standardize conversions from string. * [ADD][BRK] Pass the parameter name to request data attributes. [QueryField] and [FormField] can be used without specifying a field name: the parameter name will be used as field name. * [DEL] TransformAsync extension method * [FIX] Typo in file name. * [BRK] Remove the need for request handlers to return a boolean. * Use new-style typed methods in TestController. * [FIX] WebServerBase<>.Start waits forever if Prepare throws. #318 * [FIX] FileModule.Dispose throws if Start has not been called. #319 * [ADD] Reflection extensions and web api clean up * [FIX] Minor issue CORS and warnings * [FIX] Minor warnings and remove useless API * Fix wrong tag in XML docs. * [ADD] Methods UrlPath.Split and UrlPath.UnsafeSplit to split URL paths into segments. * Partially revert "[ADD] Reflection extensions and web api clean up" This reverts commit 19c8388c7c4e617aef771a12ac724b72b30cdc02, except for the eklimination of unused method WebApiModuleBase.TaskToBoolTask. * [FIX] Synchronous controller methods don't work. * Add unit tests for UrlPath. * Dictionaries are unordered: unit tests should not expect them to be ordered. * [FIX] Verify CORS module * [FIX] Bad passthrought * Fix XML docs. * Move housekeeping from WebModuleCollection to WebModule; save a try block. * Improve XML docs. * [ADD][BRK] Implement changes to WebModuleBase and derived classes to address #321: * [BRK] Make WebModuleBase.IsFinalHandler abstract. * Change WebSocketModule.IsFinalHandler to true. * [ADD][BRK] Allow to set ModuleGroup.IsFinalHandler in constructor. * [BRK] Rename IHttpContext.Handled to read-only IsHandled; add method SetHandled. * Improve XML docs. * Update README.md * [ADD][BRK] Add CancellationToken and RequestedPath properties to IHttpContext. * [FIX] Request data attributes with both generic and non-generic interfaces fail. Non-generic data request interfaces aren't even probed if there is no suitable generic interface. This commit solves the problem by deferring throwing an InvalidOperationException until non-generic data request interfaces have been probed (and hopefully used.) * Update README.md * Avoid warning about order of class members. * [ADD] Method QValueList.TryGetWeight to get the weight of a candidate. * [FIX] PreferCompression = false not respected (GitHub issue #330) * [ADD] new SWAN preview 002 * Update SWAN 2 preview 3 * Update to SWAN preview 4 * Update to SWAN Lite Preview 005 * Update litelib * [ADD] DataObject property and HttpExceptionHandler.JsonDataResponse * [UPD] Swan and dependencies * [FIX] Client-sent cookies are erroneously checked for path and domain (#338) * Update SWAN to 2.0.0-rc002 * Don't use ExceptionMessage extension, it's gone. * [ADD][BRK] ISession should not derive from IDataDictionary<,> (#335) * [BRK] Reorder members and improve XML docs in IHttpException. * Clarify XML docs, stating that some HTTP exception handlers will ignore DataObject. * [BRK] Replace JsonDataResponse with a more general way to use response serializers for HTTP ecxeptions. * [UPD] SWAN 2 FINAL * [FIX] Null HTTP exception messages are replaced by the CLR at throw time. * [ADD][BRK] Make DataResponse only send DataObject; add FullDataResponse to also send the message. * [ADD][BRK] Introduce the concept of base route (route pattern ending in "/"). * Added name param to WithWebApi method * Added configure param to WithLocalSessionManager method * Use overloads * Added name param to WithRouting method * Use overload * Added name param to With* file methods * Use overloads * Added configure param to WithModule method * Use overloads * Optimize RouteMatcher.Match for parameterless routes. * Relax slash checking on routes; normalize later. * Optimize route splitting. * [ADD][BRK] (Re)introduce routing at module collection level. * Rename IWebModule.BaseUrlPath to BaseRoute * Add property IHttpContext.Route (a RouteMatch of the requested path against the module's BaseRoute); change IHttpContext.RequestedPath implementations to return Route.SubPath * Add method IWebModule.MatchUrlPath; matching is done by each module instead of having a base URL path on module collections * Remove base URL path from WebModuleCollection * [UPD] LiteLib library in Sample * Update appveyor.yml --- .gitignore | 10 +- .travis.yml | 6 +- Unosquare.Labs.EmbedIO.sln => EmbedIO.sln | 14 +- ...sln.DotSettings => EmbedIO.sln.DotSettings | 52 +- LICENSE | 10 +- README.md | 368 +++------- StyleCop.Analyzers.ruleset | 75 +- appveyor.yml | 14 +- docfx.json | 6 +- src/EmbedIO.Samples/AppDbContext.cs | 42 ++ .../EmbedIO.Samples.csproj} | 14 +- .../JsonGridDataRequestAttribute.cs | 14 + src/EmbedIO.Samples/PeopleController.cs | 66 ++ .../Person.cs | 13 +- src/EmbedIO.Samples/Program.cs | 135 ++++ src/EmbedIO.Samples/WebSocketChatModule.cs | 36 + .../WebSocketTerminalModule.cs | 98 +++ .../html/css/embedio-icon.png | Bin .../html/css/embedio.png | Bin .../html/css/theme.css | 0 .../html/favicon.ico | Bin .../html/index.html | 0 .../html/partials/app-menu.html | 0 .../html/partials/app-person.html | 0 .../html/scripts/app.controllers.js | 0 .../html/scripts/app.directives.js | 0 .../html/scripts/app.js | 0 .../html/scripts/app.routes.js | 0 .../html/scripts/app.services.js | 0 .../html/scripts/tubular/tubular-bundle.css | 0 .../html/scripts/tubular/tubular-bundle.js | 0 .../scripts/tubular/tubular-bundle.min.css | 0 .../scripts/tubular/tubular-bundle.min.js | 0 .../html/views/chat.html | 0 .../html/views/cmd.html | 0 .../html/views/home.html | 0 .../html/views/people.html | 0 .../html/views/tubular.html | 0 src/EmbedIO.Testing/EmbedIO.Testing.csproj | 25 + src/EmbedIO.Testing/HttpClientExtensions.cs | 29 + .../HttpResponseMessageExtensions.cs | 26 + src/EmbedIO.Testing/ITestWebServer.cs | 15 + .../Internal/AdditionalHttpMethods.cs | 11 + src/EmbedIO.Testing/Internal/TestContext.cs | 109 +++ .../TestMessageHandler.ResponseHeaderType.cs | 17 + .../Internal/TestMessageHandler.cs | 95 +++ src/EmbedIO.Testing/Internal/TestRequest.cs | 141 ++++ src/EmbedIO.Testing/Internal/TestResponse.cs | 75 ++ .../MockFileProvider.MockDirectory.cs | 53 ++ .../MockFileProvider.MockDirectoryEntry.cs | 19 + .../MockFileProvider.MockFile.cs | 39 ++ src/EmbedIO.Testing/MockFileProvider.cs | 213 ++++++ src/EmbedIO.Testing/MockMimeTypeProvider.cs | 29 + .../EmbedIO.Testing}/Resources/index.html | 3 +- .../EmbedIO.Testing}/Resources/sub/index.html | 3 +- src/EmbedIO.Testing/StockResource.cs | 191 ++++++ src/EmbedIO.Testing/TestHttpClient.cs | 60 ++ src/EmbedIO.Testing/TestWebServer.cs | 116 ++++ src/EmbedIO.Testing/TestWebServerOptions.cs | 9 + src/EmbedIO/Actions/ActionModule.cs | 54 ++ src/EmbedIO/Actions/RedirectModule.cs | 99 +++ .../BasicAuthenticationModule.cs | 46 ++ .../BasicAuthenticationModuleBase.cs | 103 +++ .../BasicAuthenticationModuleExtensions.cs | 34 + .../CompressionMethod.cs | 10 +- src/EmbedIO/CompressionMethodNames.cs | 27 + src/EmbedIO/Cors/CorsModule.cs | 130 ++++ .../EmbedIO.csproj} | 14 +- src/EmbedIO/EmbedIOInternalErrorException.cs | 64 ++ src/EmbedIO/ExceptionHandler.cs | 155 +++++ src/EmbedIO/ExceptionHandlerCallback.cs | 22 + src/EmbedIO/Files/DirectoryLister.cs | 20 + src/EmbedIO/Files/FileCache.Section.cs | 185 +++++ src/EmbedIO/Files/FileCache.cs | 177 +++++ src/EmbedIO/Files/FileModule.cs | 649 ++++++++++++++++++ src/EmbedIO/Files/FileModuleExtensions.cs | 261 +++++++ src/EmbedIO/Files/FileRequestHandler.cs | 53 ++ .../Files/FileRequestHandlerCallback.cs | 13 + src/EmbedIO/Files/FileSystemProvider.cs | 188 +++++ src/EmbedIO/Files/IDirectoryLister.cs | 35 + src/EmbedIO/Files/IFileProvider.cs | 61 ++ src/EmbedIO/Files/Internal/Base64Utility.cs | 12 + src/EmbedIO/Files/Internal/EntityTag.cs | 28 + src/EmbedIO/Files/Internal/FileCacheItem.cs | 167 +++++ .../Files/Internal/HtmlDirectoryLister.cs | 75 ++ .../Internal/MappedResourceInfoExtensions.cs | 8 + src/EmbedIO/Files/MappedResourceInfo.cs | 80 +++ src/EmbedIO/Files/ResourceFileProvider.cs | 96 +++ src/EmbedIO/Files/ZipFileProvider.cs | 108 +++ src/EmbedIO/HttpContextExtensions-Items.cs | 45 ++ .../HttpContextExtensions-RequestStream.cs | 63 ++ src/EmbedIO/HttpContextExtensions-Requests.cs | 188 +++++ .../HttpContextExtensions-ResponseStream.cs | 69 ++ .../HttpContextExtensions-Responses.cs | 148 ++++ src/EmbedIO/HttpContextExtensions.cs | 9 + src/EmbedIO/HttpException-Shortcuts.cs | 145 ++++ src/EmbedIO/HttpException.cs | 105 +++ src/EmbedIO/HttpExceptionHandler.cs | 151 ++++ src/EmbedIO/HttpExceptionHandlerCallback.cs | 21 + .../HttpHeaderNames.cs | 16 +- src/EmbedIO/HttpListenerMode.cs | 20 + src/EmbedIO/HttpNotAcceptableException.cs | 55 ++ .../HttpRangeNotSatisfiableException.cs | 50 ++ src/EmbedIO/HttpRedirectException.cs | 54 ++ src/EmbedIO/HttpRequestExtensions.cs | 262 +++++++ src/EmbedIO/HttpResponseExtensions.cs | 43 ++ .../HttpStatusDescription.cs | 10 +- .../Constants => EmbedIO}/HttpVerbs.cs | 4 +- src/EmbedIO/ICookieCollection.cs | 49 ++ src/EmbedIO/IHttpContext.cs | 128 ++++ src/EmbedIO/IHttpContextHandler.cs | 20 + src/EmbedIO/IHttpContextImpl.cs | 79 +++ src/EmbedIO/IHttpException.cs | 58 ++ .../Abstractions => EmbedIO}/IHttpListener.cs | 16 +- .../IHttpBase.cs => EmbedIO/IHttpMessage.cs} | 19 +- .../Abstractions => EmbedIO}/IHttpRequest.cs | 97 +-- .../Abstractions => EmbedIO}/IHttpResponse.cs | 55 +- src/EmbedIO/IMimeTypeCustomizer.cs | 45 ++ src/EmbedIO/IMimeTypeProvider.cs | 31 + src/EmbedIO/IWebModule.cs | 81 +++ src/EmbedIO/IWebModuleContainer.cs | 19 + src/EmbedIO/IWebServer.cs | 68 ++ .../Internal/BufferingResponseStream.cs | 87 +++ src/EmbedIO/Internal/CompressionStream.cs | 118 ++++ src/EmbedIO/Internal/CompressionUtility.cs | 82 +++ .../Internal/LockableNameValueCollection.cs | 9 + src/EmbedIO/Internal/MimeTypeCustomizer.cs | 63 ++ .../RequestHandlerPassThroughException.cs | 8 + src/EmbedIO/Internal/SelfCheck.cs | 19 + src/EmbedIO/Internal/TimeKeeper.cs | 27 + src/EmbedIO/Internal/UriUtility.cs | 65 ++ src/EmbedIO/Internal/WebModuleCollection.cs | 46 ++ .../MimeType.Associations.cs} | 35 +- src/EmbedIO/MimeType.cs | 174 +++++ src/EmbedIO/MimeTypeCustomizerExtensions.cs | 99 +++ src/EmbedIO/ModuleGroup.cs | 108 +++ .../Net/CookieList.cs} | 175 ++--- .../Net}/EndPointManager.cs | 36 +- .../Net}/HttpListener.cs | 50 +- .../Net/Internal}/EndPointListener.cs | 20 +- .../Net/Internal/HttpConnection.InputState.cs | 11 + .../Net/Internal/HttpConnection.LineState.cs | 12 + .../Net/Internal}/HttpConnection.cs | 96 ++- .../Net/Internal/HttpListenerContext.cs | 130 ++++ .../Internal}/HttpListenerPrefixCollection.cs | 8 +- .../HttpListenerRequest.GccDelegate.cs | 9 + .../Net/Internal}/HttpListenerRequest.cs | 164 +++-- .../Net/Internal}/HttpListenerResponse.cs | 98 ++- .../Internal/HttpListenerResponseHelper.cs | 106 +++ .../Net/Internal}/HttpResponse.cs | 28 +- .../Net/Internal}/ListenerPrefix.cs | 9 +- src/EmbedIO/Net/Internal/NetExtensions.cs | 45 ++ .../Net/Internal}/RequestStream.cs | 12 +- .../Net/Internal}/ResponseStream.cs | 113 ++- src/EmbedIO/Net/Internal/StringExtensions.cs | 67 ++ .../Net/Internal/SystemCookieCollection.cs | 56 ++ src/EmbedIO/Net/Internal/SystemHttpContext.cs | 121 ++++ .../Net/Internal/SystemHttpListener.cs | 71 ++ .../Net/Internal/SystemHttpRequest.cs} | 37 +- .../Net/Internal/SystemHttpResponse.cs} | 39 +- src/EmbedIO/RequestDeserializer.cs | 50 ++ src/EmbedIO/RequestDeserializerCallback`1.cs | 13 + src/EmbedIO/RequestHandler.cs | 61 ++ src/EmbedIO/RequestHandlerCallback.cs | 11 + src/EmbedIO/ResponseSerializer.cs | 34 + src/EmbedIO/ResponseSerializerCallback.cs | 12 + src/EmbedIO/Routing/Route.cs | 324 +++++++++ src/EmbedIO/Routing/RouteAttribute.cs | 40 ++ src/EmbedIO/Routing/RouteHandlerCallback.cs | 13 + src/EmbedIO/Routing/RouteMatch.cs | 178 +++++ src/EmbedIO/Routing/RouteMatcher.cs | 156 +++++ src/EmbedIO/Routing/RouteResolutionResult.cs | 35 + src/EmbedIO/Routing/RouteResolverBase`1.cs | 154 +++++ .../Routing/RouteResolverCollectionBase`2.cs | 152 ++++ src/EmbedIO/Routing/RouteVerbResolver.cs | 25 + .../Routing/RouteVerbResolverCollection.cs | 149 ++++ src/EmbedIO/Routing/RoutingModule.cs | 64 ++ src/EmbedIO/Routing/RoutingModuleBase.cs | 336 +++++++++ .../Routing/RoutingModuleExtensions.cs | 394 +++++++++++ .../Routing/SyncRouteHandlerCallback.cs | 10 + src/EmbedIO/Sessions/ISession.cs | 113 +++ src/EmbedIO/Sessions/ISessionManager.cs | 46 ++ src/EmbedIO/Sessions/ISessionProxy.cs | 33 + .../LocalSessionManager.SessionImpl.cs | 144 ++++ src/EmbedIO/Sessions/LocalSessionManager.cs | 303 ++++++++ src/EmbedIO/Sessions/Session.cs | 35 + src/EmbedIO/Sessions/SessionExtensions.cs | 46 ++ src/EmbedIO/Sessions/SessionProxy.cs | 157 +++++ src/EmbedIO/Utilities/HttpDate.cs | 78 +++ .../Utilities/MimeTypeProviderStack.cs | 56 ++ .../NameValueCollectionExtensions.cs | 114 +++ src/EmbedIO/Utilities/QValueList.cs | 279 ++++++++ src/EmbedIO/Utilities/QValueListExtensions.cs | 72 ++ src/EmbedIO/Utilities/StringExtensions.cs | 45 ++ src/EmbedIO/Utilities/UniqueIdGenerator.cs | 16 + src/EmbedIO/Utilities/UrlEncodedDataParser.cs | 127 ++++ src/EmbedIO/Utilities/UrlPath.cs | 315 +++++++++ src/EmbedIO/Utilities/Validate-MimeType.cs | 35 + src/EmbedIO/Utilities/Validate-Paths.cs | 95 +++ src/EmbedIO/Utilities/Validate-Rfc2616.cs | 58 ++ src/EmbedIO/Utilities/Validate-Route.cs | 32 + src/EmbedIO/Utilities/Validate.cs | 125 ++++ src/EmbedIO/WebApi/FormDataAttribute.cs | 23 + src/EmbedIO/WebApi/FormFieldAttribute.cs | 172 +++++ src/EmbedIO/WebApi/IRequestDataAttribute`1.cs | 25 + src/EmbedIO/WebApi/IRequestDataAttribute`2.cs | 24 + src/EmbedIO/WebApi/QueryDataAttribute.cs | 22 + src/EmbedIO/WebApi/QueryFieldAttribute.cs | 169 +++++ src/EmbedIO/WebApi/WebApiController.cs | 73 ++ src/EmbedIO/WebApi/WebApiModule.cs | 94 +++ src/EmbedIO/WebApi/WebApiModuleBase.cs | 599 ++++++++++++++++ src/EmbedIO/WebApi/WebApiModuleExtensions.cs | 82 +++ src/EmbedIO/WebModuleBase.cs | 152 ++++ .../WebModuleContainerExtensions-Actions.cs | 312 +++++++++ .../WebModuleContainerExtensions-Cors.cs | 53 ++ .../WebModuleContainerExtensions-Files.cs | 240 +++++++ .../WebModuleContainerExtensions-Routing.cs | 53 ++ .../WebModuleContainerExtensions-WebApi.cs | 128 ++++ src/EmbedIO/WebModuleContainerExtensions.cs | 84 +++ .../WebModuleExtensions-ExceptionHandlers.cs | 45 ++ src/EmbedIO/WebModuleExtensions.cs | 9 + src/EmbedIO/WebServer-Constants.cs | 15 + src/EmbedIO/WebServer.cs | 196 ++++++ src/EmbedIO/WebServerBase`1.cs | 347 ++++++++++ .../WebServerExtensions-ExceptionHandliers.cs | 47 ++ .../WebServerExtensions-SessionManager.cs | 43 ++ src/EmbedIO/WebServerExtensions.cs | 30 + src/EmbedIO/WebServerOptions.cs | 341 +++++++++ src/EmbedIO/WebServerOptionsBase.cs | 35 + src/EmbedIO/WebServerOptionsBaseExtensions.cs | 27 + src/EmbedIO/WebServerOptionsExtensions.cs | 294 ++++++++ src/EmbedIO/WebServerState.cs | 36 + .../WebServerStateChangedEventArgs.cs | 18 +- .../WebServerStateChangedEventHandler.cs | 9 + .../WebSockets}/CloseStatusCode.cs | 2 +- .../WebSockets}/IWebSocket.cs | 14 +- src/EmbedIO/WebSockets/IWebSocketContext.cs | 87 +++ .../WebSockets}/IWebSocketReceiveResult.cs | 2 +- src/EmbedIO/WebSockets/Internal/Fin.cs | 22 + .../WebSockets/Internal}/FragmentBuffer.cs | 13 +- src/EmbedIO/WebSockets/Internal/Mask.cs | 22 + .../WebSockets/Internal/MessageEventArgs.cs | 114 +++ .../WebSockets/Internal}/PayloadData.cs | 20 +- src/EmbedIO/WebSockets/Internal/Rsv.cs | 22 + .../WebSockets/Internal/StreamExtensions.cs | 74 ++ .../WebSockets/Internal/SystemWebSocket.cs | 78 +++ .../Internal/SystemWebSocketReceiveResult.cs} | 8 +- .../WebSockets/Internal}/WebSocket.cs | 393 ++++------- .../WebSockets/Internal/WebSocketContext.cs | 100 +++ .../WebSockets/Internal}/WebSocketFrame.cs | 88 +-- .../Internal}/WebSocketFrameStream.cs | 18 +- .../Internal}/WebSocketReceiveResult.cs | 11 +- .../WebSockets/Internal}/WebSocketStream.cs | 22 +- .../WebSockets}/Opcode.cs | 2 +- .../WebSockets}/WebSocketException.cs | 12 +- src/EmbedIO/WebSockets/WebSocketModule.cs | 638 +++++++++++++++++ .../AppDbContext.cs | 41 -- .../PeopleController.cs | 83 --- src/Unosquare.Labs.EmbedIO.Samples/Program.cs | 128 ---- .../WebSocketsChatServer.cs | 61 -- .../WebSocketsTerminalServer.cs | 132 ---- .../Abstractions/ICookieCollection.cs | 29 - .../Abstractions/IHttpContext.cs | 63 -- .../Abstractions/ISessionWebModule.cs | 59 -- .../Abstractions/IWebModule.cs | 92 --- .../Abstractions/IWebServer.cs | 117 ---- .../Abstractions/IWebSocketContext.cs | 34 - src/Unosquare.Labs.EmbedIO/AssemblyInfo.cs | 3 - .../Constants/HttpHeaders.cs | 141 ---- .../Constants/Responses.cs | 20 - .../Constants/RoutingStrategy.cs | 23 - .../Constants/Strings.cs | 43 -- .../Constants/WebServerState.cs | 28 - .../CookieCollection.cs | 46 -- .../Core/FormDataParser.cs | 82 --- src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs | 78 --- .../Core/PathMappingResult.cs | 38 - src/Unosquare.Labs.EmbedIO/Core/RamCache.cs | 48 -- src/Unosquare.Labs.EmbedIO/Core/RegexCache.cs | 43 -- .../Core/ReverseOrdinalStringComparer.cs | 19 - .../Core/VirtualPath.cs | 101 --- .../Core/VirtualPathManager.cs | 373 ---------- src/Unosquare.Labs.EmbedIO/EasyRoutes.cs | 107 --- .../Extensions.Fluent.cs | 268 -------- .../Extensions.Response.cs | 360 ---------- src/Unosquare.Labs.EmbedIO/Extensions.cs | 621 ----------------- src/Unosquare.Labs.EmbedIO/HttpContext.cs | 57 -- src/Unosquare.Labs.EmbedIO/HttpHandler.cs | 167 ----- src/Unosquare.Labs.EmbedIO/HttpListener.cs | 54 -- .../HttpListenerFactory.cs | 46 -- .../HttpListenerMode.cs | 18 - src/Unosquare.Labs.EmbedIO/MethodCache.cs | 146 ---- src/Unosquare.Labs.EmbedIO/ModuleMap.cs | 43 -- .../Modules/ActionModule.cs | 34 - .../Modules/AuthModule.cs | 119 ---- .../Modules/CorsModule.cs | 126 ---- .../Modules/FallbackModule.cs | 76 -- .../Modules/FileModuleBase.cs | 137 ---- .../Modules/LocalSessionModule.cs | 197 ------ .../Modules/RedirectModule.cs | 112 --- .../Modules/ResourceFilesModule.cs | 112 --- .../Modules/StaticFilesModule.cs | 461 ------------- .../Modules/WebApiController.cs | 141 ---- .../Modules/WebApiHandlerAttribute.cs | 63 -- .../Modules/WebApiModule.cs | 272 -------- .../Modules/WebSocketHandlerAttribute.cs | 36 - .../Modules/WebSocketsModule.cs | 167 ----- .../Modules/WebSocketsServer.cs | 439 ------------ src/Unosquare.Labs.EmbedIO/SessionInfo.cs | 65 -- .../System.Net/HttpListenerContext.cs | 74 -- .../System.Net/HttpListenerException.cs | 21 - .../System.Net/HttpVersion.cs | 20 - .../System.Net/MessageEventArgs.cs | 64 -- .../System.Net/NetExtensions.cs | 122 ---- .../System.Net/WebHeaderCollection.cs | 61 -- .../System.Net/WebSocketContext.cs | 74 -- .../System.Net/WebSocketKey.cs | 25 - .../System.Net/WebSocketState.cs | 35 - .../System.Net/WebSocketValidator.cs | 80 --- .../Tests/TestHttpClient.cs | 85 --- .../Tests/TestHttpContext.cs | 51 -- .../Tests/TestHttpRequest.cs | 103 --- .../Tests/TestHttpResponse.cs | 91 --- .../Tests/TestWebServer.cs | 155 ----- src/Unosquare.Labs.EmbedIO/WebModuleBase.cs | 90 --- src/Unosquare.Labs.EmbedIO/WebModules.cs | 98 --- src/Unosquare.Labs.EmbedIO/WebServer.cs | 354 ---------- .../WebServerOptions.cs | 278 -------- src/Unosquare.Labs.EmbedIO/WebSocket.cs | 91 --- .../WebSocketContext.cs | 32 - test/EmbedIO.Tests/ActionModuleTest.cs | 167 +++++ .../BasicAuthenticationModuleTest.cs | 69 ++ .../ContentEncodingNegotiationTest.cs | 33 + test/EmbedIO.Tests/CorsModuleTest.cs | 40 ++ test/EmbedIO.Tests/DirectoryBrowserTest.cs | 58 ++ test/EmbedIO.Tests/EmbedIO.Tests.csproj | 28 + test/EmbedIO.Tests/EndToEndFixtureBase.cs | 82 +++ test/EmbedIO.Tests/ExceptionHandlingTest.cs | 106 +++ test/EmbedIO.Tests/FluentTest.cs | 62 ++ .../GlobalSuppressions.cs | 4 +- test/EmbedIO.Tests/HttpsTest.cs | 100 +++ .../IPv6Test.cs | 34 +- test/EmbedIO.Tests/IWebServerTest.cs | 74 ++ .../Issues/Issue318_StartupDeadlock.cs | 24 + .../Issue319_FileModuleDisposeException.cs | 17 + .../Issues/Issue330_PreferCompressionFalse.cs | 31 + test/EmbedIO.Tests/LocalSessionManagerTest.cs | 150 ++++ test/EmbedIO.Tests/MimeTypeTest.cs | 42 ++ test/EmbedIO.Tests/RegexRoutingTest.cs | 47 ++ .../EmbedIO.Tests/ResourceFileProviderTest.cs | 55 ++ test/EmbedIO.Tests/RoutingTest.cs | 155 +++++ test/EmbedIO.Tests/SetUpFixture.cs | 15 + test/EmbedIO.Tests/StaticFilesModuleTest.cs | 291 ++++++++ .../TestObjects/JsonDataAttribute.cs | 28 + .../TestObjects/ObjectExtensions.cs | 7 + .../TestObjects/PeopleRepository.cs | 41 +- .../TestObjects/PersonEndToEndFixtureBase.cs | 30 + .../TestObjects/Resources.cs | 18 +- .../EmbedIO.Tests/TestObjects/StaticFolder.cs | 114 +++ .../TestObjects/TestController.cs | 70 ++ .../TestObjects/TestLocalSessionController.cs | 45 ++ .../TestObjects/TestRegexModule.Controller.cs | 26 + .../TestObjects/TestRegexModule.cs | 14 + .../TestObjects/TestWebSocket.cs | 117 +--- .../Utilities/UniqueIdGeneratorTest.cs | 25 + test/EmbedIO.Tests/Utilities/UrlPathTests.cs | 111 +++ test/EmbedIO.Tests/WebApiModuleTest.cs | 208 ++++++ test/EmbedIO.Tests/WebServerTest.cs | 204 ++++++ test/EmbedIO.Tests/WebSocketModuleTest.cs | 95 +++ .../AuthModuleTest.cs | 67 -- .../CorsModuleTest.cs | 57 -- .../DirectoryBrowserTest.cs | 44 -- .../EasyRoutesTest.cs | 137 ---- .../ExtensionTest.cs | 119 ---- .../FixtureBase.cs | 110 --- .../FluentTest.cs | 164 ----- .../Unosquare.Labs.EmbedIO.Tests/HttpsTest.cs | 91 --- .../IWebServerTest.cs | 89 --- .../LocalSessionModuleTest.cs | 187 ----- .../MultipleStaticRootsFixture.cs | 40 -- .../RegexRoutingTest.cs | 42 -- .../RegexWebApiModuleTest.cs | 109 --- .../ResourceFilesModuleTest.cs | 40 -- .../StaticFilesModuleTest.cs | 555 --------------- .../TestObjects/PersonFixtureBase.cs | 32 - .../TestObjects/TestController.cs | 135 ---- .../TestObjects/TestHelper.cs | 122 ---- .../TestObjects/TestLocalSessionController.cs | 56 -- .../TestObjects/TestRegexController.cs | 153 ----- .../TestObjects/TestRegexModule.cs | 37 - .../TestObjects/TestRoutingModule.cs | 30 - .../TestObjects/TestWebModule.cs | 27 - .../Unosquare.Labs.EmbedIO.Tests.csproj | 47 -- .../WebApiModuleTest.cs | 214 ------ .../WebServerTest.cs | 298 -------- .../WebSocketsModuleTest.cs | 112 --- .../WebSocketsModuleTestBase.cs | 57 -- .../WildcardRoutingTest.cs | 40 -- toc.yml | 4 +- 399 files changed, 21283 insertions(+), 13137 deletions(-) rename Unosquare.Labs.EmbedIO.sln => EmbedIO.sln (72%) rename Unosquare.Labs.EmbedIO.sln.DotSettings => EmbedIO.sln.DotSettings (93%) create mode 100644 src/EmbedIO.Samples/AppDbContext.cs rename src/{Unosquare.Labs.EmbedIO.Samples/Unosquare.Labs.EmbedIO.Samples.csproj => EmbedIO.Samples/EmbedIO.Samples.csproj} (76%) create mode 100644 src/EmbedIO.Samples/JsonGridDataRequestAttribute.cs create mode 100644 src/EmbedIO.Samples/PeopleController.cs rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/Person.cs (58%) create mode 100644 src/EmbedIO.Samples/Program.cs create mode 100644 src/EmbedIO.Samples/WebSocketChatModule.cs create mode 100644 src/EmbedIO.Samples/WebSocketTerminalModule.cs rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/css/embedio-icon.png (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/css/embedio.png (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/css/theme.css (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/favicon.ico (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/index.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/partials/app-menu.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/partials/app-person.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/app.controllers.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/app.directives.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/app.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/app.routes.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/app.services.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/tubular/tubular-bundle.css (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/tubular/tubular-bundle.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/tubular/tubular-bundle.min.css (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/scripts/tubular/tubular-bundle.min.js (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/views/chat.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/views/cmd.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/views/home.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/views/people.html (100%) rename src/{Unosquare.Labs.EmbedIO.Samples => EmbedIO.Samples}/html/views/tubular.html (100%) create mode 100644 src/EmbedIO.Testing/EmbedIO.Testing.csproj create mode 100644 src/EmbedIO.Testing/HttpClientExtensions.cs create mode 100644 src/EmbedIO.Testing/HttpResponseMessageExtensions.cs create mode 100644 src/EmbedIO.Testing/ITestWebServer.cs create mode 100644 src/EmbedIO.Testing/Internal/AdditionalHttpMethods.cs create mode 100644 src/EmbedIO.Testing/Internal/TestContext.cs create mode 100644 src/EmbedIO.Testing/Internal/TestMessageHandler.ResponseHeaderType.cs create mode 100644 src/EmbedIO.Testing/Internal/TestMessageHandler.cs create mode 100644 src/EmbedIO.Testing/Internal/TestRequest.cs create mode 100644 src/EmbedIO.Testing/Internal/TestResponse.cs create mode 100644 src/EmbedIO.Testing/MockFileProvider.MockDirectory.cs create mode 100644 src/EmbedIO.Testing/MockFileProvider.MockDirectoryEntry.cs create mode 100644 src/EmbedIO.Testing/MockFileProvider.MockFile.cs create mode 100644 src/EmbedIO.Testing/MockFileProvider.cs create mode 100644 src/EmbedIO.Testing/MockMimeTypeProvider.cs rename {test/Unosquare.Labs.EmbedIO.Tests => src/EmbedIO.Testing}/Resources/index.html (70%) rename {test/Unosquare.Labs.EmbedIO.Tests => src/EmbedIO.Testing}/Resources/sub/index.html (68%) create mode 100644 src/EmbedIO.Testing/StockResource.cs create mode 100644 src/EmbedIO.Testing/TestHttpClient.cs create mode 100644 src/EmbedIO.Testing/TestWebServer.cs create mode 100644 src/EmbedIO.Testing/TestWebServerOptions.cs create mode 100644 src/EmbedIO/Actions/ActionModule.cs create mode 100644 src/EmbedIO/Actions/RedirectModule.cs create mode 100644 src/EmbedIO/Authentication/BasicAuthenticationModule.cs create mode 100644 src/EmbedIO/Authentication/BasicAuthenticationModuleBase.cs create mode 100644 src/EmbedIO/Authentication/BasicAuthenticationModuleExtensions.cs rename src/{Unosquare.Labs.EmbedIO/Constants => EmbedIO}/CompressionMethod.cs (80%) create mode 100644 src/EmbedIO/CompressionMethodNames.cs create mode 100644 src/EmbedIO/Cors/CorsModule.cs rename src/{Unosquare.Labs.EmbedIO/Unosquare.Labs.EmbedIO.csproj => EmbedIO/EmbedIO.csproj} (80%) create mode 100644 src/EmbedIO/EmbedIOInternalErrorException.cs create mode 100644 src/EmbedIO/ExceptionHandler.cs create mode 100644 src/EmbedIO/ExceptionHandlerCallback.cs create mode 100644 src/EmbedIO/Files/DirectoryLister.cs create mode 100644 src/EmbedIO/Files/FileCache.Section.cs create mode 100644 src/EmbedIO/Files/FileCache.cs create mode 100644 src/EmbedIO/Files/FileModule.cs create mode 100644 src/EmbedIO/Files/FileModuleExtensions.cs create mode 100644 src/EmbedIO/Files/FileRequestHandler.cs create mode 100644 src/EmbedIO/Files/FileRequestHandlerCallback.cs create mode 100644 src/EmbedIO/Files/FileSystemProvider.cs create mode 100644 src/EmbedIO/Files/IDirectoryLister.cs create mode 100644 src/EmbedIO/Files/IFileProvider.cs create mode 100644 src/EmbedIO/Files/Internal/Base64Utility.cs create mode 100644 src/EmbedIO/Files/Internal/EntityTag.cs create mode 100644 src/EmbedIO/Files/Internal/FileCacheItem.cs create mode 100644 src/EmbedIO/Files/Internal/HtmlDirectoryLister.cs create mode 100644 src/EmbedIO/Files/Internal/MappedResourceInfoExtensions.cs create mode 100644 src/EmbedIO/Files/MappedResourceInfo.cs create mode 100644 src/EmbedIO/Files/ResourceFileProvider.cs create mode 100644 src/EmbedIO/Files/ZipFileProvider.cs create mode 100644 src/EmbedIO/HttpContextExtensions-Items.cs create mode 100644 src/EmbedIO/HttpContextExtensions-RequestStream.cs create mode 100644 src/EmbedIO/HttpContextExtensions-Requests.cs create mode 100644 src/EmbedIO/HttpContextExtensions-ResponseStream.cs create mode 100644 src/EmbedIO/HttpContextExtensions-Responses.cs create mode 100644 src/EmbedIO/HttpContextExtensions.cs create mode 100644 src/EmbedIO/HttpException-Shortcuts.cs create mode 100644 src/EmbedIO/HttpException.cs create mode 100644 src/EmbedIO/HttpExceptionHandler.cs create mode 100644 src/EmbedIO/HttpExceptionHandlerCallback.cs rename src/{Unosquare.Labs.EmbedIO => EmbedIO}/HttpHeaderNames.cs (97%) create mode 100644 src/EmbedIO/HttpListenerMode.cs create mode 100644 src/EmbedIO/HttpNotAcceptableException.cs create mode 100644 src/EmbedIO/HttpRangeNotSatisfiableException.cs create mode 100644 src/EmbedIO/HttpRedirectException.cs create mode 100644 src/EmbedIO/HttpRequestExtensions.cs create mode 100644 src/EmbedIO/HttpResponseExtensions.cs rename src/{Unosquare.Labs.EmbedIO => EmbedIO}/HttpStatusDescription.cs (98%) rename src/{Unosquare.Labs.EmbedIO/Constants => EmbedIO}/HttpVerbs.cs (94%) create mode 100644 src/EmbedIO/ICookieCollection.cs create mode 100644 src/EmbedIO/IHttpContext.cs create mode 100644 src/EmbedIO/IHttpContextHandler.cs create mode 100644 src/EmbedIO/IHttpContextImpl.cs create mode 100644 src/EmbedIO/IHttpException.cs rename src/{Unosquare.Labs.EmbedIO/Abstractions => EmbedIO}/IHttpListener.cs (85%) rename src/{Unosquare.Labs.EmbedIO/Abstractions/IHttpBase.cs => EmbedIO/IHttpMessage.cs} (54%) rename src/{Unosquare.Labs.EmbedIO/Abstractions => EmbedIO}/IHttpRequest.cs (52%) rename src/{Unosquare.Labs.EmbedIO/Abstractions => EmbedIO}/IHttpResponse.cs (53%) create mode 100644 src/EmbedIO/IMimeTypeCustomizer.cs create mode 100644 src/EmbedIO/IMimeTypeProvider.cs create mode 100644 src/EmbedIO/IWebModule.cs create mode 100644 src/EmbedIO/IWebModuleContainer.cs create mode 100644 src/EmbedIO/IWebServer.cs create mode 100644 src/EmbedIO/Internal/BufferingResponseStream.cs create mode 100644 src/EmbedIO/Internal/CompressionStream.cs create mode 100644 src/EmbedIO/Internal/CompressionUtility.cs create mode 100644 src/EmbedIO/Internal/LockableNameValueCollection.cs create mode 100644 src/EmbedIO/Internal/MimeTypeCustomizer.cs create mode 100644 src/EmbedIO/Internal/RequestHandlerPassThroughException.cs create mode 100644 src/EmbedIO/Internal/SelfCheck.cs create mode 100644 src/EmbedIO/Internal/TimeKeeper.cs create mode 100644 src/EmbedIO/Internal/UriUtility.cs create mode 100644 src/EmbedIO/Internal/WebModuleCollection.cs rename src/{Unosquare.Labs.EmbedIO/Constants/MimeTypes.cs => EmbedIO/MimeType.Associations.cs} (96%) create mode 100644 src/EmbedIO/MimeType.cs create mode 100644 src/EmbedIO/MimeTypeCustomizerExtensions.cs create mode 100644 src/EmbedIO/ModuleGroup.cs rename src/{Unosquare.Labs.EmbedIO/System.Net/CookieCollection.cs => EmbedIO/Net/CookieList.cs} (78%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net}/EndPointManager.cs (72%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net}/HttpListener.cs (79%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/EndPointListener.cs (96%) create mode 100644 src/EmbedIO/Net/Internal/HttpConnection.InputState.cs create mode 100644 src/EmbedIO/Net/Internal/HttpConnection.LineState.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/HttpConnection.cs (88%) create mode 100644 src/EmbedIO/Net/Internal/HttpListenerContext.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/HttpListenerPrefixCollection.cs (76%) create mode 100644 src/EmbedIO/Net/Internal/HttpListenerRequest.GccDelegate.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/HttpListenerRequest.cs (86%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/HttpListenerResponse.cs (75%) create mode 100644 src/EmbedIO/Net/Internal/HttpListenerResponseHelper.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/HttpResponse.cs (72%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/ListenerPrefix.cs (96%) create mode 100644 src/EmbedIO/Net/Internal/NetExtensions.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/RequestStream.cs (96%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/Net/Internal}/ResponseStream.cs (96%) create mode 100644 src/EmbedIO/Net/Internal/StringExtensions.cs create mode 100644 src/EmbedIO/Net/Internal/SystemCookieCollection.cs create mode 100644 src/EmbedIO/Net/Internal/SystemHttpContext.cs create mode 100644 src/EmbedIO/Net/Internal/SystemHttpListener.cs rename src/{Unosquare.Labs.EmbedIO/HttpRequest.cs => EmbedIO/Net/Internal/SystemHttpRequest.cs} (73%) rename src/{Unosquare.Labs.EmbedIO/HttpResponse.cs => EmbedIO/Net/Internal/SystemHttpResponse.cs} (72%) create mode 100644 src/EmbedIO/RequestDeserializer.cs create mode 100644 src/EmbedIO/RequestDeserializerCallback`1.cs create mode 100644 src/EmbedIO/RequestHandler.cs create mode 100644 src/EmbedIO/RequestHandlerCallback.cs create mode 100644 src/EmbedIO/ResponseSerializer.cs create mode 100644 src/EmbedIO/ResponseSerializerCallback.cs create mode 100644 src/EmbedIO/Routing/Route.cs create mode 100644 src/EmbedIO/Routing/RouteAttribute.cs create mode 100644 src/EmbedIO/Routing/RouteHandlerCallback.cs create mode 100644 src/EmbedIO/Routing/RouteMatch.cs create mode 100644 src/EmbedIO/Routing/RouteMatcher.cs create mode 100644 src/EmbedIO/Routing/RouteResolutionResult.cs create mode 100644 src/EmbedIO/Routing/RouteResolverBase`1.cs create mode 100644 src/EmbedIO/Routing/RouteResolverCollectionBase`2.cs create mode 100644 src/EmbedIO/Routing/RouteVerbResolver.cs create mode 100644 src/EmbedIO/Routing/RouteVerbResolverCollection.cs create mode 100644 src/EmbedIO/Routing/RoutingModule.cs create mode 100644 src/EmbedIO/Routing/RoutingModuleBase.cs create mode 100644 src/EmbedIO/Routing/RoutingModuleExtensions.cs create mode 100644 src/EmbedIO/Routing/SyncRouteHandlerCallback.cs create mode 100644 src/EmbedIO/Sessions/ISession.cs create mode 100644 src/EmbedIO/Sessions/ISessionManager.cs create mode 100644 src/EmbedIO/Sessions/ISessionProxy.cs create mode 100644 src/EmbedIO/Sessions/LocalSessionManager.SessionImpl.cs create mode 100644 src/EmbedIO/Sessions/LocalSessionManager.cs create mode 100644 src/EmbedIO/Sessions/Session.cs create mode 100644 src/EmbedIO/Sessions/SessionExtensions.cs create mode 100644 src/EmbedIO/Sessions/SessionProxy.cs create mode 100644 src/EmbedIO/Utilities/HttpDate.cs create mode 100644 src/EmbedIO/Utilities/MimeTypeProviderStack.cs create mode 100644 src/EmbedIO/Utilities/NameValueCollectionExtensions.cs create mode 100644 src/EmbedIO/Utilities/QValueList.cs create mode 100644 src/EmbedIO/Utilities/QValueListExtensions.cs create mode 100644 src/EmbedIO/Utilities/StringExtensions.cs create mode 100644 src/EmbedIO/Utilities/UniqueIdGenerator.cs create mode 100644 src/EmbedIO/Utilities/UrlEncodedDataParser.cs create mode 100644 src/EmbedIO/Utilities/UrlPath.cs create mode 100644 src/EmbedIO/Utilities/Validate-MimeType.cs create mode 100644 src/EmbedIO/Utilities/Validate-Paths.cs create mode 100644 src/EmbedIO/Utilities/Validate-Rfc2616.cs create mode 100644 src/EmbedIO/Utilities/Validate-Route.cs create mode 100644 src/EmbedIO/Utilities/Validate.cs create mode 100644 src/EmbedIO/WebApi/FormDataAttribute.cs create mode 100644 src/EmbedIO/WebApi/FormFieldAttribute.cs create mode 100644 src/EmbedIO/WebApi/IRequestDataAttribute`1.cs create mode 100644 src/EmbedIO/WebApi/IRequestDataAttribute`2.cs create mode 100644 src/EmbedIO/WebApi/QueryDataAttribute.cs create mode 100644 src/EmbedIO/WebApi/QueryFieldAttribute.cs create mode 100644 src/EmbedIO/WebApi/WebApiController.cs create mode 100644 src/EmbedIO/WebApi/WebApiModule.cs create mode 100644 src/EmbedIO/WebApi/WebApiModuleBase.cs create mode 100644 src/EmbedIO/WebApi/WebApiModuleExtensions.cs create mode 100644 src/EmbedIO/WebModuleBase.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions-Actions.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions-Cors.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions-Files.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions-Routing.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions-WebApi.cs create mode 100644 src/EmbedIO/WebModuleContainerExtensions.cs create mode 100644 src/EmbedIO/WebModuleExtensions-ExceptionHandlers.cs create mode 100644 src/EmbedIO/WebModuleExtensions.cs create mode 100644 src/EmbedIO/WebServer-Constants.cs create mode 100644 src/EmbedIO/WebServer.cs create mode 100644 src/EmbedIO/WebServerBase`1.cs create mode 100644 src/EmbedIO/WebServerExtensions-ExceptionHandliers.cs create mode 100644 src/EmbedIO/WebServerExtensions-SessionManager.cs create mode 100644 src/EmbedIO/WebServerExtensions.cs create mode 100644 src/EmbedIO/WebServerOptions.cs create mode 100644 src/EmbedIO/WebServerOptionsBase.cs create mode 100644 src/EmbedIO/WebServerOptionsBaseExtensions.cs create mode 100644 src/EmbedIO/WebServerOptionsExtensions.cs create mode 100644 src/EmbedIO/WebServerState.cs rename src/{Unosquare.Labs.EmbedIO/Core => EmbedIO}/WebServerStateChangedEventArgs.cs (61%) create mode 100644 src/EmbedIO/WebServerStateChangedEventHandler.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets}/CloseStatusCode.cs (99%) rename src/{Unosquare.Labs.EmbedIO/Abstractions => EmbedIO/WebSockets}/IWebSocket.cs (90%) create mode 100644 src/EmbedIO/WebSockets/IWebSocketContext.cs rename src/{Unosquare.Labs.EmbedIO/Abstractions => EmbedIO/WebSockets}/IWebSocketReceiveResult.cs (95%) create mode 100644 src/EmbedIO/WebSockets/Internal/Fin.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/FragmentBuffer.cs (72%) create mode 100644 src/EmbedIO/WebSockets/Internal/Mask.cs create mode 100644 src/EmbedIO/WebSockets/Internal/MessageEventArgs.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/PayloadData.cs (83%) create mode 100644 src/EmbedIO/WebSockets/Internal/Rsv.cs create mode 100644 src/EmbedIO/WebSockets/Internal/StreamExtensions.cs create mode 100644 src/EmbedIO/WebSockets/Internal/SystemWebSocket.cs rename src/{Unosquare.Labs.EmbedIO/WebSocketReceiveResult.cs => EmbedIO/WebSockets/Internal/SystemWebSocketReceiveResult.cs} (66%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/WebSocket.cs (57%) create mode 100644 src/EmbedIO/WebSockets/Internal/WebSocketContext.cs rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/WebSocketFrame.cs (75%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/WebSocketFrameStream.cs (94%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/WebSocketReceiveResult.cs (79%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets/Internal}/WebSocketStream.cs (83%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets}/Opcode.cs (97%) rename src/{Unosquare.Labs.EmbedIO/System.Net => EmbedIO/WebSockets}/WebSocketException.cs (86%) create mode 100644 src/EmbedIO/WebSockets/WebSocketModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO.Samples/AppDbContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO.Samples/PeopleController.cs delete mode 100644 src/Unosquare.Labs.EmbedIO.Samples/Program.cs delete mode 100644 src/Unosquare.Labs.EmbedIO.Samples/WebSocketsChatServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO.Samples/WebSocketsTerminalServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/ICookieCollection.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/IHttpContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/ISessionWebModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/IWebModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/IWebServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/AssemblyInfo.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Constants/HttpHeaders.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Constants/Responses.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Constants/RoutingStrategy.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Constants/Strings.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Constants/WebServerState.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/CookieCollection.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/FormDataParser.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/RamCache.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/RegexCache.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/EasyRoutes.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Extensions.Fluent.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Extensions.Response.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Extensions.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/HttpContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/HttpHandler.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/HttpListener.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/HttpListenerFactory.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/HttpListenerMode.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/MethodCache.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/ModuleMap.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/ActionModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/AuthModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/FallbackModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/FileModuleBase.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/LocalSessionModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/RedirectModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/ResourceFilesModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebApiController.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebApiHandlerAttribute.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebApiModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebSocketHandlerAttribute.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebSocketsModule.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Modules/WebSocketsServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/SessionInfo.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerException.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/HttpVersion.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/MessageEventArgs.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/NetExtensions.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/WebHeaderCollection.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/WebSocketContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/WebSocketKey.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/WebSocketState.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/System.Net/WebSocketValidator.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Tests/TestHttpClient.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Tests/TestHttpContext.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Tests/TestHttpRequest.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Tests/TestHttpResponse.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Tests/TestWebServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebModuleBase.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebModules.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebServer.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebServerOptions.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebSocket.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/WebSocketContext.cs create mode 100644 test/EmbedIO.Tests/ActionModuleTest.cs create mode 100644 test/EmbedIO.Tests/BasicAuthenticationModuleTest.cs create mode 100644 test/EmbedIO.Tests/ContentEncodingNegotiationTest.cs create mode 100644 test/EmbedIO.Tests/CorsModuleTest.cs create mode 100644 test/EmbedIO.Tests/DirectoryBrowserTest.cs create mode 100644 test/EmbedIO.Tests/EmbedIO.Tests.csproj create mode 100644 test/EmbedIO.Tests/EndToEndFixtureBase.cs create mode 100644 test/EmbedIO.Tests/ExceptionHandlingTest.cs create mode 100644 test/EmbedIO.Tests/FluentTest.cs rename test/{Unosquare.Labs.EmbedIO.Tests => EmbedIO.Tests}/GlobalSuppressions.cs (83%) create mode 100644 test/EmbedIO.Tests/HttpsTest.cs rename test/{Unosquare.Labs.EmbedIO.Tests => EmbedIO.Tests}/IPv6Test.cs (52%) create mode 100644 test/EmbedIO.Tests/IWebServerTest.cs create mode 100644 test/EmbedIO.Tests/Issues/Issue318_StartupDeadlock.cs create mode 100644 test/EmbedIO.Tests/Issues/Issue319_FileModuleDisposeException.cs create mode 100644 test/EmbedIO.Tests/Issues/Issue330_PreferCompressionFalse.cs create mode 100644 test/EmbedIO.Tests/LocalSessionManagerTest.cs create mode 100644 test/EmbedIO.Tests/MimeTypeTest.cs create mode 100644 test/EmbedIO.Tests/RegexRoutingTest.cs create mode 100644 test/EmbedIO.Tests/ResourceFileProviderTest.cs create mode 100644 test/EmbedIO.Tests/RoutingTest.cs create mode 100644 test/EmbedIO.Tests/SetUpFixture.cs create mode 100644 test/EmbedIO.Tests/StaticFilesModuleTest.cs create mode 100644 test/EmbedIO.Tests/TestObjects/JsonDataAttribute.cs create mode 100644 test/EmbedIO.Tests/TestObjects/ObjectExtensions.cs rename test/{Unosquare.Labs.EmbedIO.Tests => EmbedIO.Tests}/TestObjects/PeopleRepository.cs (76%) create mode 100644 test/EmbedIO.Tests/TestObjects/PersonEndToEndFixtureBase.cs rename test/{Unosquare.Labs.EmbedIO.Tests => EmbedIO.Tests}/TestObjects/Resources.cs (65%) create mode 100644 test/EmbedIO.Tests/TestObjects/StaticFolder.cs create mode 100644 test/EmbedIO.Tests/TestObjects/TestController.cs create mode 100644 test/EmbedIO.Tests/TestObjects/TestLocalSessionController.cs create mode 100644 test/EmbedIO.Tests/TestObjects/TestRegexModule.Controller.cs create mode 100644 test/EmbedIO.Tests/TestObjects/TestRegexModule.cs rename test/{Unosquare.Labs.EmbedIO.Tests => EmbedIO.Tests}/TestObjects/TestWebSocket.cs (98%) create mode 100644 test/EmbedIO.Tests/Utilities/UniqueIdGeneratorTest.cs create mode 100644 test/EmbedIO.Tests/Utilities/UrlPathTests.cs create mode 100644 test/EmbedIO.Tests/WebApiModuleTest.cs create mode 100644 test/EmbedIO.Tests/WebServerTest.cs create mode 100644 test/EmbedIO.Tests/WebSocketModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/AuthModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/DirectoryBrowserTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/EasyRoutesTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/ExtensionTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/FixtureBase.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/FluentTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/HttpsTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/IWebServerTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/LocalSessionModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/MultipleStaticRootsFixture.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/RegexRoutingTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/RegexWebApiModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/ResourceFilesModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PersonFixtureBase.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestController.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestHelper.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestLocalSessionController.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexController.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexModule.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRoutingModule.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebModule.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/WebServerTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTest.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTestBase.cs delete mode 100644 test/Unosquare.Labs.EmbedIO.Tests/WildcardRoutingTest.cs diff --git a/.gitignore b/.gitignore index 371dabcfc..48b89a73f 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ ipch/ # ReSharper is a .NET coding add-in _ReSharper*/ +*.DotSettings.user *.[Rr]e[Ss]harper # TeamCity is a build add-in @@ -155,12 +156,11 @@ packages/* !packages/repositories.config project.lock.json TestResult.xml -/src/Unosquare.Labs.EmbedIO.Samples/mydbfile.db +/src/EmbedIO.Samples/mydbfile.db .vs/ -/src/Unosquare.Labs.EmbedIO.Command/nuget.config -/src/Unosquare.Labs.EmbedIO/nuget.config -/src/Unosquare.Labs.EmbedIO.Samples/nuget.config -/test/Unosquare.Labs.EmbedIO.Tests/nuget.config +/src/EmbedIO/nuget.config +/src/EmbedIO.Samples/nuget.config +/test/EmbedIO.Tests/nuget.config *.targets /.vscode /_site diff --git a/.travis.yml b/.travis.yml index 41f2fd08c..d0f5a1f28 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: csharp -solution: Unosquare.Labs.EmbedIO.Lib.sln +solution: EmbedIO.Lib.sln notifications: email: false slack: unolabs:cbusXPH6pBwZ35rVDzi4k4ve @@ -13,6 +13,6 @@ matrix: - os: osx osx_image: xcode9.1 install: - - dotnet restore Unosquare.Labs.EmbedIO.sln + - dotnet restore EmbedIO.sln script: - - dotnet test ./test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj -c Release -f netcoreapp2.2 + - dotnet test ./test/EmbedIO.Tests/EmbedIO.Tests.csproj -c Release diff --git a/Unosquare.Labs.EmbedIO.sln b/EmbedIO.sln similarity index 72% rename from Unosquare.Labs.EmbedIO.sln rename to EmbedIO.sln index f971f3825..dc415bcf2 100644 --- a/Unosquare.Labs.EmbedIO.sln +++ b/EmbedIO.sln @@ -9,17 +9,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .travis.yml = .travis.yml appveyor.yml = appveyor.yml + LICENSE = LICENSE README.md = README.md StyleCop.Analyzers.ruleset = StyleCop.Analyzers.ruleset EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{39AC0FCD-3DBB-4C9B-87EE-873D31165F28}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unosquare.Labs.EmbedIO", "src\Unosquare.Labs.EmbedIO\Unosquare.Labs.EmbedIO.csproj", "{76B8EFC5-EDEF-4E31-9E78-164E8687B1AB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EmbedIO", "src\EmbedIO\EmbedIO.csproj", "{76B8EFC5-EDEF-4E31-9E78-164E8687B1AB}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unosquare.Labs.EmbedIO.Tests", "test\Unosquare.Labs.EmbedIO.Tests\Unosquare.Labs.EmbedIO.Tests.csproj", "{C91F303C-DFF3-4260-8A66-0EBEF53F69F5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EmbedIO.Tests", "test\EmbedIO.Tests\EmbedIO.Tests.csproj", "{C91F303C-DFF3-4260-8A66-0EBEF53F69F5}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unosquare.Labs.EmbedIO.Samples", "src\Unosquare.Labs.EmbedIO.Samples\Unosquare.Labs.EmbedIO.Samples.csproj", "{5B312B76-1C92-4C50-9A44-BAF738377306}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EmbedIO.Samples", "src\EmbedIO.Samples\EmbedIO.Samples.csproj", "{5B312B76-1C92-4C50-9A44-BAF738377306}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EmbedIO.Testing", "src\EmbedIO.Testing\EmbedIO.Testing.csproj", "{822A86B3-4294-44D6-8C5E-53EC508E40AC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -39,6 +42,10 @@ Global {5B312B76-1C92-4C50-9A44-BAF738377306}.Debug|Any CPU.Build.0 = Debug|Any CPU {5B312B76-1C92-4C50-9A44-BAF738377306}.Release|Any CPU.ActiveCfg = Release|Any CPU {5B312B76-1C92-4C50-9A44-BAF738377306}.Release|Any CPU.Build.0 = Release|Any CPU + {822A86B3-4294-44D6-8C5E-53EC508E40AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {822A86B3-4294-44D6-8C5E-53EC508E40AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {822A86B3-4294-44D6-8C5E-53EC508E40AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {822A86B3-4294-44D6-8C5E-53EC508E40AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -47,6 +54,7 @@ Global {76B8EFC5-EDEF-4E31-9E78-164E8687B1AB} = {97BC259A-4E78-4BA8-8F4D-2656BC78BB34} {C91F303C-DFF3-4260-8A66-0EBEF53F69F5} = {39AC0FCD-3DBB-4C9B-87EE-873D31165F28} {5B312B76-1C92-4C50-9A44-BAF738377306} = {97BC259A-4E78-4BA8-8F4D-2656BC78BB34} + {822A86B3-4294-44D6-8C5E-53EC508E40AC} = {97BC259A-4E78-4BA8-8F4D-2656BC78BB34} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {197F095C-03FC-4632-8C1F-CC038D75CEAB} diff --git a/Unosquare.Labs.EmbedIO.sln.DotSettings b/EmbedIO.sln.DotSettings similarity index 93% rename from Unosquare.Labs.EmbedIO.sln.DotSettings rename to EmbedIO.sln.DotSettings index 1d5a992db..8270d7731 100644 --- a/Unosquare.Labs.EmbedIO.sln.DotSettings +++ b/EmbedIO.sln.DotSettings @@ -424,7 +424,6 @@ WARNING WARNING WARNING - ECMAScript 2016 Implicit Implicit True @@ -434,7 +433,18 @@ END_OF_LINE TOGETHER_SAME_LINE END_OF_LINE + False True + NEVER + NEVER + False + NEVER + False + True + True + True + False + CHOP_ALWAYS True True True @@ -455,40 +465,6 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /> 2 True - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> <Configurator><ConnectList /></Configurator> True True @@ -497,9 +473,15 @@ System.CodeDom.Compiler.GeneratedCodeAttribute <data><AttributeFilter ClassMask="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" IsEnabled="True" /><AttributeFilter ClassMask="System.CodeDom.Compiler.GeneratedCodeAttribute" IsEnabled="True" /></data> True + True + True True + True + True True + True + True True True \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8cd699c4c..5aed07b3b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014-2018 Unosquare, Mario A. Di Vece and Geovanni Perez +Copyright (c) 2014-2019 Unosquare, Mario A. Di Vece and Geovanni Perez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -20,16 +20,18 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------- - Portions of this software are distributed under the following licenses: -------------------------------------------------------------------------- +--------------------------------------------------------------------------- + Portions of this software are redistributed under the following licenses: +--------------------------------------------------------------------------- Component name : MimeTypeMap +Used in : EmbedIO.MimeTypes class Copyright holder: Samuel Neff License type : MIT License URL : https://github.com/samuelneff/MimeTypeMap/blob/master/LICENSE Component name : System.Net classes +Used in : Several types in EmbedIO.Net and EmbedIO.Net.Internal namespaces Copyright holder: The .NET Foundation License type : MIT X11 License URL : https://github.com/mono/mono/blob/master/LICENSE diff --git a/README.md b/README.md index 6f2f70681..5ff81c342 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![Analytics](https://ga-beacon.appspot.com/UA-8535255-2/unosquare/embedio/)](https://github.com/igrigorik/ga-beacon) [![Build status](https://ci.appveyor.com/api/projects/status/w59t7sct3a8ir96t?svg=true)](https://ci.appveyor.com/project/geoperez/embedio) [![Build Status](https://travis-ci.org/unosquare/embedio.svg?branch=master)](https://travis-ci.org/unosquare/embedio) - [![NuGet version](https://badge.fury.io/nu/embedio.svg)](https://www.nuget.org/packages/Embedio) - [![NuGet](https://img.shields.io/nuget/dt/embedio.svg)](https://www.nuget.org/packages/Embedio) + [![NuGet version](https://badge.fury.io/nu/embedio.svg)](https://www.nuget.org/packages/EmbedIO) + [![NuGet](https://img.shields.io/nuget/dt/embedio.svg)](https://www.nuget.org/packages/EmbedIO) [![Coverage Status](https://coveralls.io/repos/unosquare/embedio/badge.svg?branch=master)](https://coveralls.io/r/unosquare/embedio?branch=master) [![BuiltWithDotnet](https://builtwithdot.net/project/105/embedio/badge)](https://builtwithdot.net/project/105/embedio) [![Slack](https://img.shields.io/badge/chat-slack-blue.svg)](https://join.slack.com/t/embedio/shared_invite/enQtNjcwMjgyNDk4NzUzLWQ4YTE2MDQ2MWRhZGIyMTRmNTU0YmY4MmE3MTJmNTY4MmZiZDAzM2M4MTljMmVmNjRiZDljM2VjYjI5MjdlM2U) @@ -12,22 +12,20 @@ *:star: Please star this project if you find it useful!* -**This README is for EmbedIO v2.x. Click [here](https://github.com/unosquare/embedio/tree/v1.X) if you are still using EmbedIO v1.x.** +**This README is for EmbedIO v3.x. Click [here](https://github.com/unosquare/embedio/tree/v2.X) if you are still using EmbedIO v2.x.** - [Overview](#overview) - - [EmbedIO 2.0 - What's new](#embedio-20---whats-new) + - [EmbedIO 3.0 - What's new](#embedio-30---whats-new) - [Some usage scenarios](#some-usage-scenarios) - [Installation](#installation) - [Usage](#usage) - [WebServer Setup](#webserver-setup) - - [IHttpContext Extension Methods](#ihttpcontext-extension-methods) - - [Easy Routes](#easy-routes) - - [Serving Files from Assembly](#serving-files-from-assembly) -- [Support for SSL](#support-for-ssl) -- [Examples](#examples) - - [Basic Example](#basic-example) - - [REST API Example](#rest-api-example) + - [Reading from a POST body as a dictionary (application/x-www-form-urlencoded)](#reading-from-a-post-body-as-a-json-payload-applicationjson) + - [Reading from a POST body as a JSON payload (application/json)](#reading-from-a-post-body-as-a-json-payload-applicationjson) + - [Reading from a POST body as a FormData (multipart/form-data)](#reading-from-a-post-body-as-a-formdata-multipartform-data) + - [Writing a binary stream](#writing-a-binary-stream) - [WebSockets Example](#websockets-example) +- [Support for SSL](#support-for-ssl) - [Related Projects and Nugets](#related-projects-and-nugets) - [Special Thanks](#special-thanks) @@ -46,22 +44,13 @@ A tiny, cross-platform, module based, MIT-licensed web server for .NET Framework * Handle sessions with the built-in LocalSessionWebModule * WebSockets support * CORS support. Origin, Header and Method validation with OPTIONS preflight -* Supports HTTP 206 Partial Content +* HTTP 206 Partial Content support * Support [Xamarin Forms](https://github.com/unosquare/embedio/tree/master/src/EmbedIO.Forms.Sample) * And many more options in the same package -### EmbedIO 2.0 - What's new - -#### Breaking changes -* `WebApiController` is renewed. Reduce the methods overhead removing the WebServer and Context arguments. See examples below. -* `RoutingStrategy.Regex` is the default routing scheme. +### EmbedIO 3.0 - What's new -#### Additional changes -* `IHttpListener` is runtime/platform independent, you can choose Unosquare `HttpListener` implementation with NET472 or NETSTANDARD20. This separation of implementations brings new access to interfaces from common Http objects like `IHttpRequest`, `IHttpContext` and more. -* `IWebServer` is a new interface to create custom web server implementation, like a Test Web Server where all the operations are in-memory to speed up unit testing. Similar to [TestServer from OWIN](https://msdn.microsoft.com/en-us/library/microsoft.owin.testing.testserver(v=vs.113).aspx) -* General improvements in how the Unosquare `HttpListner` is working and code clean-up. - -*Note* - We encourage to upgrade to the newest EmbedIO version. Branch version 1.X will no longer be maintained, and issues will be tested against 2.X and resolved just there. +The major version 3.0 includes a lot of changes in how the webserver process the incoming request and the pipeline of the Web Modules. You can check a complete list of changes and a upgrade guide for v2 users [here](https://github.com/unosquare/embedio/wiki/Upgrade-from-v2). ### Some usage scenarios: @@ -90,108 +79,9 @@ PM> Install-Package EmbedIO ## Usage -### WebServer Setup - -### IHttpContext Extension Methods - -By adding the namespace `Unosquare.Labs.EmbedIO` to your class, you can use some helpful extension methods for `IHttpContext`, `IHttpResponse` and `IHttpRequest`. These methods can be used in any Web module (like [Fallback Module](https://unosquare.github.io/embedio/api/Unosquare.Labs.EmbedIO.Modules.FallbackModule.html)) or inside a [WebAPI Controller](https://unosquare.github.io/embedio/api/Unosquare.Labs.EmbedIO.Modules.WebApiController.html) method. - -Below, some common scenarios using a WebAPI Controller method as body function: - -#### Reading from a POST body as a dictionary (application/x-www-form-urlencoded) - -For reading a dictionary from a HTTP Request body you can use [RequestFormDataDictionaryAsync](https://unosquare.github.io/embedio/api/Unosquare.Labs.EmbedIO.Extensions.html#Unosquare_Labs_EmbedIO_Extensions_RequestFormDataDictionaryAsync_Unosquare_Labs_EmbedIO_IHttpContext_). This method works directly from `IHttpContext` and returns the key-value pairs sent by using the Contet-Type 'application/x-www-form-urlencoded'. - -```csharp - [WebApiHandler(HttpVerbs.Post, "/api/data")] - public async Task PostData() - { - var data = await HttpContext.RequestFormDataDictionaryAsync(); - // Perform an operation with the data - await SaveData(data); - - return true; - } -``` - -#### Reading from a POST body as a JSON payload (application/json) - -For reading a JSON payload and deserialize it to an object from a HTTP Request body you can use [ParseJson](https://unosquare.github.io/embedio/api/Unosquare.Labs.EmbedIO.Extensions.html#Unosquare_Labs_EmbedIO_Extensions_ParseJsonAsync__1_Unosquare_Labs_EmbedIO_IHttpContext_). This method works directly from `IHttpContext` and returns an object of the type specified in the generic type. - -```csharp - [WebApiHandler(HttpVerbs.Post, "/api/data")] - public async Task PostJsonData() - { - var data = HttpContext.ParseJson(); - // Perform an operation with the data - await SaveData(data); - - return true; - } -``` - -#### Reading from a POST body as a FormData (multipart/form-data) - -EmbedIO doesn't provide the functionality to read from a Multipart FormData stream. But you can check the [HttpMultipartParser Nuget](https://www.nuget.org/packages/HttpMultipartParser/) and connect the Request input directly to the HttpMultipartParser, very helpful and small library. - -There is [another solution](http://stackoverflow.com/questions/7460088/reading-file-input-from-a-multipart-form-data-post) but it requires this [Microsoft Nuget](https://www.nuget.org/packages/Microsoft.AspNet.WebApi.Client). - -#### Writing a binary stream - -For writing a binary stream directly to the Response Output Stream you can use [BinaryResponseAsync](https://unosquare.github.io/embedio/api/Unosquare.Labs.EmbedIO.Extensions.html#Unosquare_Labs_EmbedIO_Extensions_BinaryResponseAsync_Unosquare_Labs_EmbedIO_IHttpContext_System_IO_Stream_System_Boolean_System_Threading_CancellationToken_). This method has an overload to use `IHttpContext` and you need to set the Content-Type beforehand. - -```csharp - [WebApiHandler(HttpVerbs.Get, "/api/binary")] - public async Task GetBinary() - { - var stream = new MemoryStream(); - - // Call a fictional external source - await GetExternalStream(stream); - - return await HttpContext.BinaryResponseAsync(stream); - } -``` - -### Easy Routes - -### Serving Files from Assembly - -You can use files from Assembly Resources directly with EmbedIO. They will be served as local files. This is a good practice when you want to provide a web server solution in a single file. - -First, you need to add the `ResourceFilesModule` module to your `IWebServer`. The `ResourceFilesModule` constructor takes two arguments, the Assembly reference where the Resources are located and the path to the Resources (Usually this path is the Assembly name plus the word "Resources"). - -```csharp -using (var server = new WebServer(url)) -{ - server.RegisterModule(new ResourceFilesModule(typeof(MyProgram).Assembly, - "Unosquare.MyProgram.Resources")); - - // Continue with the server set up and initialization -} -``` - -And that's all. The module will read the files in the Assembly using the second argument as the base path. For example, if you have a folder containing an image, the resource path can be `Unosquare.MyProgram.Resources.MyFolder.Image.jpg` and the relative URL is `/MyFolder/Image.jpg`. - -## Support for SSL - -Both HTTP listeners (Microsoft and Unosquare) can open a web server using SSL. This support is for Windows only (for now) and you need to manually register your certificate or use the `WebServerOptions` class to initialize a new `WebServer` instance. This section will provide some examples of how to use SSL but first a brief explanation of how SSL works on Windows. - -For Windows Vista or better, Microsoft provides Network Shell (`netsh`). This command line tool allows to map an IP-port to a certificate, so incoming HTTP request can upgrade the connection to a secure stream using the provided certificate. EmbedIO can read or register certificates to a default store (My/LocalMachine) and use them against a netsh `sslcert` for binding the first `https` prefix registered. - -For Windows XP and Mono, you can use manually the `httpcfg` for registering the binding. - -### Using a PFX file and AutoRegister option +Working with EmbedIO is pretty simple, check the follow sections to start coding right away. You can find more useful recipes and implementation details in the [Cookbook](https://github.com/unosquare/embedio/wiki/Cookbook). -The more practical case to use EmbedIO with SSL is the `AutoRegister` option. You need to create a `WebServerOptions` instance with the path to a PFX file and the `AutoRegister` flag on. This options will try to get or register the certificate to the default certificate store. Then it will use the certificate thumbprint to register with `netsh` the FIRST `https` prefix registered on the options. - -### Using AutoLoad option - -If you already have a certificate on the default certificate store and the binding is also registered in `netsh`, you can use `Autoload` flag and optionally provide a certificate thumbprint. If the certificate thumbprint is not provided, EmbedIO will read the data from `netsh`. After getting successfully the certificate from the store, the raw data is passed to the WebServer. - -## Examples - -### Basic Example +### WebServer Setup Please note the comments are the important part here. More info is available in the samples. @@ -199,8 +89,8 @@ Please note the comments are the important part here. More info is available in namespace Unosquare { using System; - using Unosquare.Labs.EmbedIO; - using Unosquare.Labs.EmbedIO.Modules; + using EmbedIO; + using EmbedIO.Modules; class Program { @@ -215,207 +105,157 @@ namespace Unosquare url = args[0]; // Our web server is disposable. - using (var server = new WebServer(url)) + using (var server = CreateWebServer(url)) { - // First, we will configure our web server by adding Modules. - // Please note that order DOES matter. - // ================================================================================================ - // If we want to enable sessions, we simply register the LocalSessionModule - // Beware that this is an in-memory session storage mechanism so, avoid storing very large objects. - // You can use the server.GetSession() method to get the SessionInfo object and manupulate it. - // You could potentially implement a distributed session module using something like Redis - server.WithLocalSession(); - - // Here we setup serving of static files - server.RegisterModule(new StaticFilesModule("c:/web")); - // The static files module will cache small files in ram until it detects they have been modified. - server.Module().UseRamCache = true; - // Once we've registered our modules and configured them, we call the RunAsync() method. server.RunAsync(); - // Fire up the browser to show the content if we are debugging! -#if DEBUG var browser = new System.Diagnostics.Process() { StartInfo = new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true } }; browser.Start(); -#endif // Wait for any key to be pressed before disposing of our web server. // In a service, we'd manage the lifecycle of our web server using // something like a BackgroundWorker or a ManualResetEvent. Console.ReadKey(true); } } + + // Create and configure our web server. + private static WebServer CreateWebServer(string url) + { + var server = new WebServer(o => o + .WithUrlPrefix(url) + .WithMode(HttpListenerMode.EmbedIO)) + // First, we will configure our web server by adding Modules. + .WithLocalSessionManager() + .WithWebApi("/api", m => m + .WithController()) + .WithModule(new WebSocketChatModule("/chat")) + .WithModule(new WebSocketTerminalModule("/terminal")) + .WithStaticFolder("/", HtmlRootPath, true, m => m + .WithContentCaching(UseFileCache)) // Add static files after other modules to avoid conflicts + .WithModule(new ActionModule("/", HttpVerbs.Any, ctx => ctx.SendDataAsync(new { Message = "Error" }))); + + // Listen for state changes. + server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info(); + + return server; + } } } ``` -### REST API Example - -The WebApi module supports two routing strategies: Wildcard and Regex. By default, the WebApi module will use the **Regex Routing Strategy** trying to match and resolve the values from a route template, in a similar fashion to Microsoft's Web API. +### Reading from a POST body as a dictionary (application/x-www-form-urlencoded) -**Note** - Wilcard routing will be dropped in the next major version of EmbedIO. We advise to use Regex only. - -A method with the following route `/api/people/{id}` is going to match any request URL with three segments: the first two `api` and `people` and the last -one is going to be parsed or converted to the type in the `id` argument of the handling method signature. Please read on if this was confusing as it is -much simpler than it sounds. Additionally, you can put multiple values to match, for example `/api/people/{mainSkill}/{age}`, and receive the -parsed values from the URL straight into the arguments of your handler method. - -During server setup: +For reading a dictionary from an HTTP Request body inside a WebAPI method you can add an argument to your method with the attribute `FormData`. ```csharp -var server = new WebServer("http://localhost:9696/", RoutingStrategy.Regex); - -server.RegisterModule(new WebApiModule()); -server.Module().RegisterController(); + [Route(HttpVerbs.Post, "/data")] + public async Task PostData([FormData] NameValueCollection data) + { + // Perform an operation with the data + await SaveData(data); + } ``` -And our controller class (using default Regex Strategy) looks like: +### Reading from a POST body as a JSON payload (application/json) -```csharp -// A controller is a class where the WebApi module will find available -// endpoints. The class must extend WebApiController. -public class PeopleController : WebApiController -{ - // You need to add a default constructor where the first argument - // is an IHttpContext - public PeopleController(IHttpContext context) - : base(context) - { - } +For reading a JSON payload and deserialize it to an object from an HTTP Request body you can use [GetRequestDataAsync](#). This method works directly from `IHttpContext` and returns an object of the type specified in the generic type. - // You need to include the WebApiHandler attribute to each method - // where you want to export an endpoint. The method should return - // bool or Task. - [WebApiHandler(HttpVerbs.Get, "/api/people/{id}")] - public async Task GetPersonById(int id) +```csharp + [Route(HttpVerbs.Post, "/data")] + public async Task PostJsonData() { - try - { - // This is fake call to a Repository - var person = await PeopleRepository.GetById(id); - return await Ok(person); - } - catch (Exception ex) - { - return await InternalServerError(ex); - } + var data = HttpContext.GetRequestDataAsync(); + + // Perform an operation with the data + await SaveData(data); } - - // You can override the default headers and add custom headers to each API Response. - public override void SetDefaultHeaders() => HttpContext.NoCache(); -} ``` -The `SetDefaultHeaders` method will add a no-cache policy to all Web API responses. If you plan to handle a differente policy or even custom headers to each different Web API method we recommend you override this method as you need. - -The previous default strategy (Wildcard) matches routes using the asterisk `*` character in the route. **For example:** +### Reading from a POST body as a FormData (multipart/form-data) -- The route `/api/people/*` will match any request with a URL starting with the two first URL segments `api` and -`people` and ending with anything. The route `/api/people/hello` will be matched. -- You can also use wildcards in the middle of the route. The route `/api/people/*/details` will match requests -starting with the two first URL segments `api` and `people`, and end with a `details` segment. The route `/api/people/hello/details` will be matched. +EmbedIO doesn't provide the functionality to read from a Multipart FormData stream. But you can check the [HttpMultipartParser Nuget](https://www.nuget.org/packages/HttpMultipartParser/) and connect the Request input directly to the HttpMultipartParser, very helpful and small library. -During server setup: +There is [another solution](http://stackoverflow.com/questions/7460088/reading-file-input-from-a-multipart-form-data-post) but it requires this [Microsoft Nuget](https://www.nuget.org/packages/Microsoft.AspNet.WebApi.Client). -```csharp -var server = new WebServer("http://localhost:9696/", RoutingStrategy.Regex); +### Writing a binary stream -server.RegisterModule(new WebApiModule()); -server.Module().RegisterController(); -``` +You can open the Response Output Stream with the extension [OpenResponseStream](). ```csharp -public class PeopleController : WebApiController -{ - public PeopleController(IHttpContext context) - : base(context) - { - } - - [WebApiHandler(HttpVerbs.Get, "/api/people/*")] - public async Task GetPeopleOrPersonById() + [Route(HttpVerbs.Get, "/binary")] + public async Task GetBinary() { - var lastSegment = Request.Url.Segments.Last(); - - // If the last segment is a backslash, return all - // the collection. This endpoint call a fake Repository. - if (lastSegment.EndsWith("/")) - return await Ok(await PeopleRepository.GetAll()); - - if (int.TryParse(lastSegment, out var id)) - { - return await Ok(await PeopleRepository.GetById(id)); - } - - throw new KeyNotFoundException("Key Not Found: " + lastSegment); + // Call a fictional external source + using (var stream = HttpContext.OpenResponseStream()) + await stream.WriteAsync(dataBuffer, 0, 0); } -} ``` ### WebSockets Example -*During server setup:* +Working with WebSocket is pretty simple, you just need to implement the abstract class `WebSocketModule` and register the module to your Web server as follow: ```csharp -server.RegisterModule(new WebSocketsModule()); -server.Module().RegisterWebSocketsServer("/chat"); +server..WithModule(new WebSocketChatModule("/chat")); ``` -*And our web sockets server class looks like:* +And our web sockets server class looks like: ```csharp /// /// Defines a very simple chat server /// -public class WebSocketsChatServer : WebSocketsServer +public class WebSocketsChatServer : WebSocketModule { - public WebSocketsChatServer() - : base(true) + public WebSocketsChatServer(string urlPath) + : base(urlPath, true) { // placeholder } - public override string ServerName => "Chat Server"; - - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) - { - foreach (var ws in WebSockets) - { - if (ws != context) - Send(ws, rxBuffer.ToText()); - } - } - - protected override void OnClientConnected( + /// + protected override Task OnMessageReceivedAsync( IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - Send(context, "Welcome to the chat room!"); + byte[] rxBuffer, + IWebSocketReceiveResult rxResult) + => SendToOthersAsync(context, Encoding.GetString(rxBuffer)); + + /// + protected override Task OnClientConnectedAsync(IWebSocketContext context) + => Task.WhenAll( + SendAsync(context, "Welcome to the chat room!"), + SendToOthersAsync(context, "Someone joined the chat room.")); - foreach (var ws in WebSockets) - { - if (ws != context) - Send(ws, "Someone joined the chat room."); - } - } - - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) - { - // placeholder - } + /// + protected override Task OnClientDisconnectedAsync(IWebSocketContext context) + => SendToOthersAsync(context, "Someone left the chat room."); - protected override void OnClientDisconnected(IWebSocketContext context) - { - Broadcast("Someone left the chat room."); - } + private Task SendToOthersAsync(IWebSocketContext context, string payload) + => BroadcastAsync(payload, c => c != context); } ``` +## Support for SSL + +Both HTTP listeners (Microsoft and Unosquare) can open a web server using SSL. This support is for Windows only (for now) and you need to manually register your certificate or use the `WebServerOptions` class to initialize a new `WebServer` instance. This section will provide some examples of how to use SSL but first a brief explanation of how SSL works on Windows. + +For Windows Vista or better, Microsoft provides Network Shell (`netsh`). This command line tool allows to map an IP-port to a certificate, so incoming HTTP request can upgrade the connection to a secure stream using the provided certificate. EmbedIO can read or register certificates to a default store (My/LocalMachine) and use them against a netsh `sslcert` for binding the first `https` prefix registered. + +For Windows XP and Mono, you can use manually the `httpcfg` for registering the binding. + +### Using a PFX file and AutoRegister option + +The more practical case to use EmbedIO with SSL is the `AutoRegister` option. You need to create a `WebServerOptions` instance with the path to a PFX file and the `AutoRegister` flag on. This options will try to get or register the certificate to the default certificate store. Then it will use the certificate thumbprint to register with `netsh` the FIRST `https` prefix registered on the options. + +### Using AutoLoad option + +If you already have a certificate on the default certificate store and the binding is also registered in `netsh`, you can use `Autoload` flag and optionally provide a certificate thumbprint. If the certificate thumbprint is not provided, EmbedIO will read the data from `netsh`. After getting successfully the certificate from the store, the raw data is passed to the WebServer. + ## Related Projects and Nugets Name | Author | Description diff --git a/StyleCop.Analyzers.ruleset b/StyleCop.Analyzers.ruleset index b7afea236..a2cb0c7ab 100644 --- a/StyleCop.Analyzers.ruleset +++ b/StyleCop.Analyzers.ruleset @@ -7,14 +7,21 @@ - + + + + + + + + @@ -72,34 +79,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index dad7b1b46..44335e95c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: '1.13.{build}' +version: '3.0.{build}' image: - Visual Studio 2017 - Ubuntu @@ -26,7 +26,6 @@ before_build: - ps: cinst resharper-clt.portable -y --no-progress - dotnet restore --verbosity q - cmd: mkdir tools -- cmd: nuget install OpenCover -Version 4.6.519 -OutputDirectory tools - cmd: nuget install coveralls.net -Version 0.7.0 -OutputDirectory tools - ps: | $date_now = Get-Date @@ -36,14 +35,14 @@ before_build: $command = "http add sslcert ipport=0.0.0.0:5555 certhash=$certThumb appid={$guid}" $command | netsh build_script: -- cmd: msbuild /verbosity:quiet /p:Configuration=Release Unosquare.Labs.EmbedIO.sln +- cmd: msbuild /verbosity:quiet /p:Configuration=Release EmbedIO.sln - cmd: | cd src/EmbedIO.Forms.Sample/ msbuild /t:restore EmbedIO.Forms.Sample.sln msbuild /verbosity:quiet /p:Configuration=Release EmbedIO.Forms.Sample.sln cd .. cd .. -- cmd: InspectCode --swea -o=inspectcode.xml -s=Error --verbosity=ERROR Unosquare.Labs.EmbedIO.sln +- cmd: InspectCode --swea -o=inspectcode.xml -s=Error --verbosity=ERROR EmbedIO.sln - ps: | [xml]$xml = Get-Content inspectcode.xml if ($xml.Report.Issues.HasChildNodes) @@ -52,16 +51,15 @@ build_script: throw "inspectcode.exe found issues with severity level Error; see inspectcode.xml for details" } test_script: -- dotnet test test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj -c Release -f netcoreapp2.2 -- cmd: tools\OpenCover.4.6.519\tools\OpenCover.Console.exe -target:"%ProgramFiles%\dotnet\dotnet.exe" -targetargs:"test test\Unosquare.Labs.EmbedIO.Tests\Unosquare.Labs.EmbedIO.Tests.csproj -c Release -f net472" -output:coverage.xml -filter:"+[Unosquare.Labs.EmbedIO*]* -[Unosquare.Labs.EmbedIO.Test*]*" -register:userdotnet +- dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:Exclude=[NUnit3.TestAdapter]* test/EmbedIO.Tests/EmbedIO.Tests.csproj -c Release - ps: | if(-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { - tools\coveralls.net.0.7.0\tools\csmacnz.Coveralls.exe --opencover -i coverage.xml --serviceName appveyor --jobId $Env:APPVEYOR_BUILD_NUMBER + tools\coveralls.net.0.7.0\tools\csmacnz.Coveralls.exe --opencover -i C:\projects\embedio\test\EmbedIO.Tests\coverage.opencover.xml --serviceName appveyor --jobId $Env:APPVEYOR_BUILD_NUMBER } after_build: - ps: | - if(-Not $env:APPVEYOR_PULL_REQUEST_TITLE) + if(-Not $env:APPVEYOR_PULL_REQUEST_TITLE -And $env:APPVEYOR_REPO_BRANCH -eq "master") { git config --global credential.helper store Add-Content "$env:USERPROFILE\.git-credentials" "https://$($env:access_token):x-oauth-basic@github.com`n" diff --git a/docfx.json b/docfx.json index fe21bfd7b..0b3e53b5c 100644 --- a/docfx.json +++ b/docfx.json @@ -3,7 +3,7 @@ { "src": [ { - "files": [ "src/Unosquare.Labs.EmbedIO/**/*.cs" ], + "files": [ "src/EmbedIO/**/*.cs" ], "exclude": [ "**/bin/**", "**/obj/**" ] } ], @@ -30,13 +30,13 @@ ], "resource": [ { - "files": [ "best-practices/resources/**", "embedio.png", "src/Unosquare.Labs.EmbedIO.Samples/html/favicon.ico"] + "files": [ "best-practices/resources/**", "embedio.png"] } ], "globalMetadata": { "_appTitle": "Unosquare EmbedIO", "_enableSearch": true, - "_appFaviconPath": "src/Unosquare.Labs.EmbedIO.Samples/html/favicon.ico", + "_appFaviconPath": "src/Command/favicon.ico", "_appLogoPath": "best-practices/resources/images/logo.png", "_docLogo": "embedio.png" }, diff --git a/src/EmbedIO.Samples/AppDbContext.cs b/src/EmbedIO.Samples/AppDbContext.cs new file mode 100644 index 000000000..8fd6b4ef2 --- /dev/null +++ b/src/EmbedIO.Samples/AppDbContext.cs @@ -0,0 +1,42 @@ +using Unosquare.Labs.LiteLib; + +namespace EmbedIO.Samples +{ + internal sealed class AppDbContext : LiteDbContext + { + public AppDbContext() : base("mydbfile.db", false) + { + // map this context to the database file mydbfile.db and don't use any logging capabilities. + } + + public LiteDbSet People { get; set; } + + public static void InitDatabase() + { + using (var dbContext = new AppDbContext()) + { + foreach (var person in dbContext.People.SelectAll()) + dbContext.People.Delete(person); + + dbContext.People.Insert(new Person + { + Name = "Mario Di Vece", + Age = 31, + EmailAddress = "mario@unosquare.com" + }); + dbContext.People.Insert(new Person + { + Name = "Geovanni Perez", + Age = 32, + EmailAddress = "geovanni.perez@unosquare.com" + }); + dbContext.People.Insert(new Person + { + Name = "Luis Gonzalez", + Age = 29, + EmailAddress = "luis.gonzalez@unosquare.com" + }); + } + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/Unosquare.Labs.EmbedIO.Samples.csproj b/src/EmbedIO.Samples/EmbedIO.Samples.csproj similarity index 76% rename from src/Unosquare.Labs.EmbedIO.Samples/Unosquare.Labs.EmbedIO.Samples.csproj rename to src/EmbedIO.Samples/EmbedIO.Samples.csproj index 5315583fd..929c21306 100644 --- a/src/Unosquare.Labs.EmbedIO.Samples/Unosquare.Labs.EmbedIO.Samples.csproj +++ b/src/EmbedIO.Samples/EmbedIO.Samples.csproj @@ -2,9 +2,9 @@ netcoreapp2.2;net472 - Unosquare.Labs.EmbedIO.Samples + EmbedIO.Samples Exe - Unosquare.Labs.EmbedIO.Samples + false 7.3 ..\..\StyleCop.Analyzers.ruleset @@ -16,11 +16,7 @@ - - - - - + all runtime; build; native; contentfiles; analyzers @@ -29,4 +25,8 @@ + + + + diff --git a/src/EmbedIO.Samples/JsonGridDataRequestAttribute.cs b/src/EmbedIO.Samples/JsonGridDataRequestAttribute.cs new file mode 100644 index 000000000..4de7a5eb5 --- /dev/null +++ b/src/EmbedIO.Samples/JsonGridDataRequestAttribute.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.WebApi; +using Unosquare.Tubular.ObjectModel; + +namespace EmbedIO.Samples +{ + [AttributeUsage(AttributeTargets.Parameter)] + public class JsonGridDataRequestAttribute : Attribute, IRequestDataAttribute + { + public Task GetRequestDataAsync(WebApiController controller, string parameterName) + => controller.HttpContext.GetRequestDataAsync(RequestDeserializer.Json); + } +} \ No newline at end of file diff --git a/src/EmbedIO.Samples/PeopleController.cs b/src/EmbedIO.Samples/PeopleController.cs new file mode 100644 index 000000000..3f8d9b409 --- /dev/null +++ b/src/EmbedIO.Samples/PeopleController.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using EmbedIO.WebApi; +using Unosquare.Tubular; +using Unosquare.Tubular.ObjectModel; + +namespace EmbedIO.Samples +{ + // A very simple controller to handle People CRUD. + // Notice how it Inherits from WebApiController and the methods have WebApiHandler attributes + // This is for sampling purposes only. + public sealed class PeopleController : WebApiController, IDisposable + { + private readonly AppDbContext _dbContext = new AppDbContext(); + + public void Dispose() => _dbContext.Dispose(); + + // Gets all records. + // This will respond to + // GET http://localhost:9696/api/people + [Route(HttpVerbs.Get, "/people")] + public async Task> GetAllPeople() => await _dbContext.People.SelectAllAsync().ConfigureAwait(false); + + // Gets the first record. + // This will respond to + // GET http://localhost:9696/api/people/first + [Route(HttpVerbs.Get, "/people/first")] + public async Task GetFirstPeople() => (await _dbContext.People.SelectAllAsync().ConfigureAwait(false)).First(); + + // Gets a single record. + // This will respond to + // GET http://localhost:9696/api/people/1 + // GET http://localhost:9696/api/people/{n} + // + // If the given ID is not found, this method will return false. + // By default, WebApiModule will then respond with "404 Not Found". + // + // If the given ID cannot be converted to an integer, an exception will be thrown. + // By default, WebApiModule will then respond with "500 Internal Server Error". + [Route(HttpVerbs.Get, "/people/{id?}")] + public async Task GetPeople(int id) + => await _dbContext.People.SingleAsync(id).ConfigureAwait(false) + ?? throw HttpException.NotFound(); + + // Posts the people Tubular model. + [Route(HttpVerbs.Post, "/people")] + public async Task PostPeople([JsonGridDataRequest] GridDataRequest gridDataRequest) + => gridDataRequest.CreateGridDataResponse((await _dbContext.People.SelectAllAsync().ConfigureAwait(false)).AsQueryable()); + + // Echoes request form data in JSON format. + [Route(HttpVerbs.Post, "/echo")] + public Dictionary Echo([FormData] NameValueCollection data) + => data.ToDictionary(); + + // Select by name + [Route(HttpVerbs.Get, "/peopleByName/{name}")] + public async Task GetPeopleByName(string name) + => await _dbContext.People.FirstOrDefaultAsync(nameof(Person.Name), name).ConfigureAwait(false) + ?? throw HttpException.NotFound(); + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/Person.cs b/src/EmbedIO.Samples/Person.cs similarity index 58% rename from src/Unosquare.Labs.EmbedIO.Samples/Person.cs rename to src/EmbedIO.Samples/Person.cs index 0ef6c2f1e..55c23ca73 100644 --- a/src/Unosquare.Labs.EmbedIO.Samples/Person.cs +++ b/src/EmbedIO.Samples/Person.cs @@ -1,8 +1,9 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using LiteLib; - using Swan; +using Unosquare.Labs.LiteLib; +using Swan; +using Swan.Cryptography; +namespace EmbedIO.Samples +{ /// /// /// A simple model representing a person @@ -16,6 +17,8 @@ public class Person : LiteModel [LiteIndex] public string EmailAddress { get; set; } - public string PhotoUrl => $"http://www.gravatar.com/avatar/{EmailAddress.ComputeMD5().ToUpperHex()}.png?s=100"; +#pragma warning disable 0618 // "Use a better hasher." - Not our fault if gravatar.com uses MD5. + public string PhotoUrl => $"http://www.gravatar.com/avatar/{Hasher.ComputeMD5(EmailAddress).ToUpperHex()}.png?s=100"; +#pragma warning restore 0618 } } \ No newline at end of file diff --git a/src/EmbedIO.Samples/Program.cs b/src/EmbedIO.Samples/Program.cs new file mode 100644 index 000000000..ddd030867 --- /dev/null +++ b/src/EmbedIO.Samples/Program.cs @@ -0,0 +1,135 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Actions; +using EmbedIO.Files; +using EmbedIO.WebApi; +using Swan; +using Swan.Logging; + +namespace EmbedIO.Samples +{ + internal class Program + { + private const bool OpenBrowser = true; + private const bool UseFileCache = true; + + private static void Main(string[] args) + { + var url = args.Length > 0 ? args[0] : "http://*:8877"; + + AppDbContext.InitDatabase(); + + using (var ctSource = new CancellationTokenSource()) + { + Task.WaitAll( + RunWebServerAsync(url, ctSource.Token), + OpenBrowser ? ShowBrowserAsync(url.Replace("*", "localhost"), ctSource.Token) : Task.CompletedTask, + WaitForUserBreakAsync(ctSource.Cancel)); + } + + // Clean up + "Bye".Info(nameof(Program)); + Terminal.Flush(); + + Console.WriteLine("Press any key to exit."); + WaitForKeypress(); + } + + // Gets the local path of shared files. + // When debugging, take them directly from source so we can edit and reload. + // Otherwise, take them from the deployment directory. + public static string HtmlRootPath + { + get + { + var assemblyPath = Path.GetDirectoryName(typeof(Program).Assembly.Location); + +#if DEBUG + return Path.Combine(Directory.GetParent(assemblyPath).Parent.Parent.FullName, "html"); +#else + return Path.Combine(assemblyPath, "html"); +#endif + } + } + + // Create and configure our web server. + private static WebServer CreateWebServer(string url) + { +#pragma warning disable CA2000 // Call Dispose on object - this is a factory method. + var server = new WebServer(o => o + .WithUrlPrefix(url) + .WithMode(HttpListenerMode.EmbedIO)) + .WithLocalSessionManager() + .WithCors( + // Origins, separated by comma without last slash + "http://unosquare.github.io,http://run.plnkr.co", + // Allowed headers + "content-type, accept", + // Allowed methods + "post") + .WithWebApi("/api", m => m + .WithController()) + .WithModule(new WebSocketChatModule("/chat")) + .WithModule(new WebSocketTerminalModule("/terminal")) + .WithStaticFolder("/", HtmlRootPath, true, m => m + .WithContentCaching(UseFileCache)) // Add static files after other modules to avoid conflicts + .WithModule(new ActionModule("/", HttpVerbs.Any, ctx => ctx.SendDataAsync(new { Message = "Error" }))); + + // Listen for state changes. + server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info(); + + return server; +#pragma warning restore CA2000 + } + + // Create and run a web server. + private static async Task RunWebServerAsync(string url, CancellationToken cancellationToken) + { + using (var server = CreateWebServer(url)) + { + await server.RunAsync(cancellationToken).ConfigureAwait(false); + } + } + + // Open the default browser on the web server's home page. + private static async Task ShowBrowserAsync(string url, CancellationToken cancellationToken) + { + // Be sure to run in parallel. + await Task.Yield(); + + // Fire up the browser to show the content! + using (var browser = new Process()) + { + browser.StartInfo = new ProcessStartInfo(url) { + UseShellExecute = true + }; + browser.Start(); + } + } + + // Prompt the user to press any key; when a key is next pressed, + // call the specified action to cancel operations. + private static async Task WaitForUserBreakAsync(Action cancel) + { + // Be sure to run in parallel. + await Task.Yield(); + + "Press any key to stop the web server.".Info(nameof(Program)); + WaitForKeypress(); + "Stopping...".Info(nameof(Program)); + cancel(); + } + + // Clear the console input buffer and wait for a keypress + private static void WaitForKeypress() + { + while (Console.KeyAvailable) + Console.ReadKey(true); + + Console.ReadKey(true); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Samples/WebSocketChatModule.cs b/src/EmbedIO.Samples/WebSocketChatModule.cs new file mode 100644 index 000000000..e0bcd0ded --- /dev/null +++ b/src/EmbedIO.Samples/WebSocketChatModule.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using EmbedIO.WebSockets; + +namespace EmbedIO.Samples +{ + /// + /// Defines a very simple chat server. + /// + public class WebSocketChatModule : WebSocketModule + { + public WebSocketChatModule(string urlPath) + : base(urlPath, true) + { + } + + /// + protected override Task OnMessageReceivedAsync( + IWebSocketContext context, + byte[] rxBuffer, + IWebSocketReceiveResult rxResult) + => SendToOthersAsync(context, Encoding.GetString(rxBuffer)); + + /// + protected override Task OnClientConnectedAsync(IWebSocketContext context) + => Task.WhenAll( + SendAsync(context, "Welcome to the chat room!"), + SendToOthersAsync(context, "Someone joined the chat room.")); + + /// + protected override Task OnClientDisconnectedAsync(IWebSocketContext context) + => SendToOthersAsync(context, "Someone left the chat room."); + + private Task SendToOthersAsync(IWebSocketContext context, string payload) + => BroadcastAsync(payload, c => c != context); + } +} \ No newline at end of file diff --git a/src/EmbedIO.Samples/WebSocketTerminalModule.cs b/src/EmbedIO.Samples/WebSocketTerminalModule.cs new file mode 100644 index 000000000..67713a939 --- /dev/null +++ b/src/EmbedIO.Samples/WebSocketTerminalModule.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Net.WebSockets; +using System.Threading.Tasks; +using EmbedIO.WebSockets; + +namespace EmbedIO.Samples +{ + /// + /// Define a command-line interface terminal. + /// + public class WebSocketTerminalModule : WebSocketModule + { + private readonly ConcurrentDictionary _processes = new ConcurrentDictionary(); + + public WebSocketTerminalModule(string urlPath) + : base(urlPath, true) + { + } + + /// + protected override Task OnMessageReceivedAsync(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + => _processes.TryGetValue(context, out var process) + ? process.StandardInput.WriteLineAsync(Encoding.GetString(rxBuffer)) + : Task.CompletedTask; + + /// + protected override Task OnClientConnectedAsync(IWebSocketContext context) + { +#pragma warning disable CA2000 // Call Dispose on object - will do in OnClientDisconnectedAsync. + var process = new Process + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + ErrorDialog = false, + FileName = "cmd.exe", + RedirectStandardError = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WorkingDirectory = Environment.CurrentDirectory + } + }; +#pragma warning restore CA2000 + + process.OutputDataReceived += async (s, e) => await SendBufferAsync(s as Process, e.Data).ConfigureAwait(false); + + process.ErrorDataReceived += async (s, e) => await SendBufferAsync(s as Process, e.Data).ConfigureAwait(false); + + process.Exited += async (s, e) => + { + var ctx = FindContext(s as Process); + if (ctx?.WebSocket?.State == WebSocketState.Open) + await CloseAsync(ctx).ConfigureAwait(false); + }; + + _processes.TryAdd(context, process); + + process.Start(); + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return Task.CompletedTask; + } + + /// + protected override Task OnClientDisconnectedAsync(IWebSocketContext context) + { + if (_processes.TryRemove(context, out var process)) + { + if (!process.HasExited) + process.Kill(); + + process.Dispose(); + } + + return Task.CompletedTask; + } + + private IWebSocketContext FindContext(Process p) + => _processes.FirstOrDefault(kvp => kvp.Value == p).Key; + + private Task SendBufferAsync(Process process, string buffer) + { + if (process.HasExited) + return Task.CompletedTask; + + var context = FindContext(process); + return context?.WebSocket?.State == WebSocketState.Open + ? SendAsync(context, buffer) + : Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/css/embedio-icon.png b/src/EmbedIO.Samples/html/css/embedio-icon.png similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/css/embedio-icon.png rename to src/EmbedIO.Samples/html/css/embedio-icon.png diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/css/embedio.png b/src/EmbedIO.Samples/html/css/embedio.png similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/css/embedio.png rename to src/EmbedIO.Samples/html/css/embedio.png diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/css/theme.css b/src/EmbedIO.Samples/html/css/theme.css similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/css/theme.css rename to src/EmbedIO.Samples/html/css/theme.css diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/favicon.ico b/src/EmbedIO.Samples/html/favicon.ico similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/favicon.ico rename to src/EmbedIO.Samples/html/favicon.ico diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/index.html b/src/EmbedIO.Samples/html/index.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/index.html rename to src/EmbedIO.Samples/html/index.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/partials/app-menu.html b/src/EmbedIO.Samples/html/partials/app-menu.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/partials/app-menu.html rename to src/EmbedIO.Samples/html/partials/app-menu.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/partials/app-person.html b/src/EmbedIO.Samples/html/partials/app-person.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/partials/app-person.html rename to src/EmbedIO.Samples/html/partials/app-person.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.controllers.js b/src/EmbedIO.Samples/html/scripts/app.controllers.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.controllers.js rename to src/EmbedIO.Samples/html/scripts/app.controllers.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.directives.js b/src/EmbedIO.Samples/html/scripts/app.directives.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.directives.js rename to src/EmbedIO.Samples/html/scripts/app.directives.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.js b/src/EmbedIO.Samples/html/scripts/app.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.js rename to src/EmbedIO.Samples/html/scripts/app.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.routes.js b/src/EmbedIO.Samples/html/scripts/app.routes.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.routes.js rename to src/EmbedIO.Samples/html/scripts/app.routes.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.services.js b/src/EmbedIO.Samples/html/scripts/app.services.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/app.services.js rename to src/EmbedIO.Samples/html/scripts/app.services.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.css b/src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.css similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.css rename to src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.css diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.js b/src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.js rename to src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.css b/src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.css similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.css rename to src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.css diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.js b/src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.js similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.js rename to src/EmbedIO.Samples/html/scripts/tubular/tubular-bundle.min.js diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/views/chat.html b/src/EmbedIO.Samples/html/views/chat.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/views/chat.html rename to src/EmbedIO.Samples/html/views/chat.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/views/cmd.html b/src/EmbedIO.Samples/html/views/cmd.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/views/cmd.html rename to src/EmbedIO.Samples/html/views/cmd.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/views/home.html b/src/EmbedIO.Samples/html/views/home.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/views/home.html rename to src/EmbedIO.Samples/html/views/home.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/views/people.html b/src/EmbedIO.Samples/html/views/people.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/views/people.html rename to src/EmbedIO.Samples/html/views/people.html diff --git a/src/Unosquare.Labs.EmbedIO.Samples/html/views/tubular.html b/src/EmbedIO.Samples/html/views/tubular.html similarity index 100% rename from src/Unosquare.Labs.EmbedIO.Samples/html/views/tubular.html rename to src/EmbedIO.Samples/html/views/tubular.html diff --git a/src/EmbedIO.Testing/EmbedIO.Testing.csproj b/src/EmbedIO.Testing/EmbedIO.Testing.csproj new file mode 100644 index 000000000..e3b852970 --- /dev/null +++ b/src/EmbedIO.Testing/EmbedIO.Testing.csproj @@ -0,0 +1,25 @@ + + + + 3.0.0 + EmbedIO Web Server Testing + netstandard2.0 + 7.3 + true + + + + + + + + + + + + + + + + + diff --git a/src/EmbedIO.Testing/HttpClientExtensions.cs b/src/EmbedIO.Testing/HttpClientExtensions.cs new file mode 100644 index 000000000..0e8c04612 --- /dev/null +++ b/src/EmbedIO.Testing/HttpClientExtensions.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace EmbedIO.Testing +{ + /// + /// Provides extension methods for . + /// + public static class HttpClientExtensions + { + /// + /// Asynchronously sends a HEAD request to a specified URL. + /// + /// The on which this method is called. + /// The request URL. + /// A whose result will be a . + public static Task HeadAsync(this HttpClient @this, string url) + => @this.SendAsync(new HttpRequestMessage(HttpMethod.Head, url)); + + /// + /// Asynchronously sends an OPTIONS request to a specified URL. + /// + /// The on which this method is called. + /// The request URL. + /// A whose result will be a . + public static Task OptionsAsync(this HttpClient @this, string url) + => @this.SendAsync(new HttpRequestMessage(HttpMethod.Options, url)); + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/HttpResponseMessageExtensions.cs b/src/EmbedIO.Testing/HttpResponseMessageExtensions.cs new file mode 100644 index 000000000..c80de3a63 --- /dev/null +++ b/src/EmbedIO.Testing/HttpResponseMessageExtensions.cs @@ -0,0 +1,26 @@ +using System.Net.Http; +using System.Threading.Tasks; + +namespace EmbedIO.Testing +{ + /// + /// Provides extension methods for + /// and tasks returning instances of . + /// + public static class HttpResponseMessageExtensions + { + /// + /// Asynchronously gets a HTTP response body as a string. + /// + /// The that will return the response. + /// A whose result will be the response body as a string. + public static async Task ReceiveStringAsync(this Task @this) + { + using (var response = await @this.ConfigureAwait(false)) + { + if (response == null) return null; + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/ITestWebServer.cs b/src/EmbedIO.Testing/ITestWebServer.cs new file mode 100644 index 000000000..fbe97366f --- /dev/null +++ b/src/EmbedIO.Testing/ITestWebServer.cs @@ -0,0 +1,15 @@ +namespace EmbedIO.Testing +{ + /// + /// Represents an object that can act as a web server, processing requests + /// directed to a fictional base URL. + /// + /// + public interface ITestWebServer : IHttpContextHandler + { + /// + /// Gets the base URL simulated by the server. + /// + string BaseUrl { get; } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/Internal/AdditionalHttpMethods.cs b/src/EmbedIO.Testing/Internal/AdditionalHttpMethods.cs new file mode 100644 index 000000000..3136dfa92 --- /dev/null +++ b/src/EmbedIO.Testing/Internal/AdditionalHttpMethods.cs @@ -0,0 +1,11 @@ +using System.Net.Http; + +namespace EmbedIO.Testing.Internal +{ +#if NETSTANDARD2_0 + internal static class AdditionalHttpMethods + { + public static readonly HttpMethod Patch = new HttpMethod("PATCH"); + } +#endif +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/Internal/TestContext.cs b/src/EmbedIO.Testing/Internal/TestContext.cs new file mode 100644 index 000000000..2c591607e --- /dev/null +++ b/src/EmbedIO.Testing/Internal/TestContext.cs @@ -0,0 +1,109 @@ +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 Swan.Logging; + +namespace EmbedIO.Testing.Internal +{ + internal sealed class TestContext : IHttpContextImpl + { + private readonly TimeKeeper _ageKeeper = new TimeKeeper(); + + private readonly Stack> _closeCallbacks = new Stack>(); + + private bool _closed; + + internal TestContext(IHttpRequest request) + { + Request = request; + User = null; + TestResponse = new TestResponse(); + 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 => TestResponse; + + internal TestResponse TestResponse { get; } + + public IPrincipal User { get; } + + public ISessionProxy Session { get; set; } + + public bool SupportCompressedRequests { get; set; } + + public IDictionary Items { get; } = new Dictionary(); + + public bool IsHandled { get; set; } + + 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 Task AcceptWebSocketAsync(IEnumerable requestedProtocols, + string acceptedProtocol, + int receiveBufferSize, + TimeSpan keepAliveInterval, + CancellationToken cancellationToken) + => throw new NotImplementedException("This HTTP context does not support the WebSocket protocol."); + + 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.Testing/Internal/TestMessageHandler.ResponseHeaderType.cs b/src/EmbedIO.Testing/Internal/TestMessageHandler.ResponseHeaderType.cs new file mode 100644 index 000000000..c318aa553 --- /dev/null +++ b/src/EmbedIO.Testing/Internal/TestMessageHandler.ResponseHeaderType.cs @@ -0,0 +1,17 @@ +namespace EmbedIO.Testing.Internal +{ + partial class TestMessageHandler + { + private enum ResponseHeaderType + { + // The header must be ignored + None, + + // The header should be added to the Content property's Headers + Content, + + // The header must be added to the response's Headers + Response + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/Internal/TestMessageHandler.cs b/src/EmbedIO.Testing/Internal/TestMessageHandler.cs new file mode 100644 index 000000000..16eed9a68 --- /dev/null +++ b/src/EmbedIO.Testing/Internal/TestMessageHandler.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.Utilities; + +namespace EmbedIO.Testing.Internal +{ + internal sealed partial class TestMessageHandler : HttpMessageHandler + { + private readonly IHttpContextHandler _handler; + + public TestMessageHandler(IHttpContextHandler handler) + { + _handler = Validate.NotNull(nameof(handler), handler); + CookieContainer = new CookieContainer(); + } + + public CookieContainer CookieContainer { get; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var serverRequest = new TestRequest(Validate.NotNull(nameof(request), request)); + var cookiesFromContainer = CookieContainer.GetCookieHeader(serverRequest.Url); + if (!string.IsNullOrEmpty(cookiesFromContainer)) + serverRequest.Headers.Add(HttpHeaderNames.Cookie, cookiesFromContainer); + + var context = new TestContext(serverRequest); + context.CancellationToken = cancellationToken; + context.Route = RouteMatch.UnsafeFromRoot(UrlPath.Normalize(serverRequest.Url.AbsolutePath, false)); + await _handler.HandleContextAsync(context).ConfigureAwait(false); + var serverResponse = context.TestResponse; + var responseCookies = serverResponse.Headers.Get(HttpHeaderNames.SetCookie); + if (!string.IsNullOrEmpty(responseCookies)) + CookieContainer.SetCookies(serverRequest.Url, responseCookies); + + var response = new HttpResponseMessage((HttpStatusCode) serverResponse.StatusCode) { + RequestMessage = request, + Version = serverResponse.ProtocolVersion, + ReasonPhrase = serverResponse.StatusDescription, + Content = serverResponse.Body == null ? null : new ByteArrayContent(serverResponse.Body), + }; + foreach (var key in serverResponse.Headers.AllKeys) + { + switch (GetResponseHeaderType(key)) + { + case ResponseHeaderType.Content: + response.Content?.Headers.Add(key, serverResponse.Headers.GetValues(key)); + break; + case ResponseHeaderType.Response: + response.Headers.Add(key, serverResponse.Headers.GetValues(key)); + break; + } + } + + return response; + } + + private static ResponseHeaderType GetResponseHeaderType(string name) + { + // Not all headers are created equal in System.Net.Http. + // If a header is a "content" header, adding it to a HttpResponseMessage directly + // will cause an InvalidOperationException. + // The list of known headers with their respective "header types" + // is conveniently hidden in an internal class of System.Net.Http, + // because nobody outside the .NET team will ever need them, right? + // https://github.com/dotnet/corefx/blob/master/src/System.Net.Http/src/System/Net/Http/Headers/KnownHeaders.cs + // Here are the "content" headers, extracted on 2019-07-06: + switch (name) + { + // Content-Length is set automatically and shall not be touched + case HttpHeaderNames.ContentLength: + return ResponseHeaderType.None; + + // These headers belong to Content + case HttpHeaderNames.Allow: + case HttpHeaderNames.ContentDisposition: + case HttpHeaderNames.ContentEncoding: + case HttpHeaderNames.ContentLanguage: + case HttpHeaderNames.ContentLocation: + case HttpHeaderNames.ContentMD5: + case HttpHeaderNames.ContentRange: + case HttpHeaderNames.ContentType: + case HttpHeaderNames.Expires: + case HttpHeaderNames.LastModified: + return ResponseHeaderType.Content; + + // All other headers belong to the response + default: + return ResponseHeaderType.Response; + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/Internal/TestRequest.cs b/src/EmbedIO.Testing/Internal/TestRequest.cs new file mode 100644 index 000000000..1d0f1cefe --- /dev/null +++ b/src/EmbedIO.Testing/Internal/TestRequest.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using EmbedIO.Net; +using EmbedIO.Utilities; +using Swan; + +namespace EmbedIO.Testing.Internal +{ + internal class TestRequest : IHttpRequest + { + private readonly HttpContent _content; + + public TestRequest(HttpRequestMessage clientRequest) + { + _content = Validate.NotNull(nameof(clientRequest), clientRequest).Content; + + var headers = new NameValueCollection(); + foreach (var pair in clientRequest.Headers) + { + var values = pair.Value.ToArray(); + switch (values.Length) + { + case 0: + headers.Add(pair.Key, string.Empty); + break; + case 1: + headers.Add(pair.Key, values[0]); + break; + default: + foreach (var value in values) + headers.Add(pair.Key, value); + + break; + } + + switch (pair.Key) + { + case HttpHeaderNames.Cookie: + Cookies = CookieList.Parse(string.Join(",", values)); + break; + } + } + + Headers = headers; + if (Cookies == null) + Cookies = new CookieList(); + + ProtocolVersion = clientRequest.Version; + KeepAlive = !(clientRequest.Headers.ConnectionClose ?? true); + RawUrl = clientRequest.RequestUri.PathAndQuery; + QueryString = UrlEncodedDataParser.Parse(clientRequest.RequestUri.Query, true, true); + HttpMethod = clientRequest.Method.ToString(); + HttpVerb = HttpMethodToVerb(clientRequest.Method); + Url = clientRequest.RequestUri; + HasEntityBody = _content != null; + ContentEncoding = Encoding.GetEncoding(_content?.Headers.ContentType?.CharSet ?? Encoding.UTF8.WebName); + RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9999); + UserAgent = clientRequest.Headers.UserAgent?.ToString(); + LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 8080); + ContentType = _content?.Headers.ContentType?.MediaType; + } + + public ICookieCollection Cookies { get; } + + public Version ProtocolVersion { get; } + + public NameValueCollection Headers { get; } + + public bool KeepAlive { get; } + + public string RawUrl { get; } + + public NameValueCollection QueryString { get; } + + public string HttpMethod { get; } + + public HttpVerbs HttpVerb { get; } + + public Uri Url { get; } + + public bool HasEntityBody { get; } + + public Stream InputStream => _content?.ReadAsStreamAsync().Await(); + + public Encoding ContentEncoding { get; } + + public IPEndPoint RemoteEndPoint { get; } + + public bool IsLocal => true; + + public bool IsSecureConnection => false; + + public string UserAgent { get; } + + public bool IsWebSocketRequest => false; + + public IPEndPoint LocalEndPoint { get; } + + public string ContentType { get; } + + public long ContentLength64 => 0; + + public bool IsAuthenticated => false; + + public Uri UrlReferrer => null; + + private static HttpVerbs HttpMethodToVerb(HttpMethod method) + { + if (method == System.Net.Http.HttpMethod.Delete) + return HttpVerbs.Delete; + + if (method == System.Net.Http.HttpMethod.Get) + return HttpVerbs.Get; + + if (method == System.Net.Http.HttpMethod.Head) + return HttpVerbs.Head; + + if (method == System.Net.Http.HttpMethod.Options) + return HttpVerbs.Options; +#if NETSTANDARD2_0 + if (method == AdditionalHttpMethods.Patch) + return HttpVerbs.Patch; +#else + if (method == System.Net.Http.HttpMethod.Patch) + return HttpVerbs.Patch; +#endif + if (method == System.Net.Http.HttpMethod.Post) + return HttpVerbs.Post; + + if (method == System.Net.Http.HttpMethod.Put) + return HttpVerbs.Put; + + return HttpVerbs.Any; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/Internal/TestResponse.cs b/src/EmbedIO.Testing/Internal/TestResponse.cs new file mode 100644 index 000000000..65fb425af --- /dev/null +++ b/src/EmbedIO.Testing/Internal/TestResponse.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Net; +using System.Text; + +namespace EmbedIO.Testing.Internal +{ + internal sealed class TestResponse : IHttpResponse, IDisposable + { + ~TestResponse() + { + Dispose(false); + } + + public WebHeaderCollection Headers { get; } = new WebHeaderCollection(); + + public int StatusCode { get; set; } = (int)HttpStatusCode.OK; + + public long ContentLength64 { get; set; } + + public string ContentType { get; set; } + + public Stream OutputStream { get; } = new MemoryStream(); + + public ICookieCollection Cookies { get; } = new Net.CookieList(); + + public Encoding ContentEncoding { get; set; } = Encoding.UTF8; + + public bool KeepAlive { get; set; } + + public bool SendChunked { get; set; } + + public Version ProtocolVersion { get; } = HttpVersion.Version11; + + public byte[] Body { get; private set; } + + public string StatusDescription { get; set; } + + internal bool IsClosed { get; private set; } + + public void SetCookie(Cookie cookie) => Cookies.Add(cookie); + + public void Close() + { + IsClosed = true; + Body = (OutputStream as MemoryStream)?.ToArray(); + + Dispose(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public string GetBodyAsString() + { + if (!(OutputStream is MemoryStream ms)) return null; + + var result = (ContentEncoding ?? Encoding.UTF8).GetString(ms.ToArray()); + + // Remove BOM + return result.Length > 0 && result[0] == 65279 ? result.Remove(0, 1) : result; + } + + private void Dispose(bool disposing) + { + if (!disposing) + return; + + OutputStream?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/MockFileProvider.MockDirectory.cs b/src/EmbedIO.Testing/MockFileProvider.MockDirectory.cs new file mode 100644 index 000000000..ca77d05a3 --- /dev/null +++ b/src/EmbedIO.Testing/MockFileProvider.MockDirectory.cs @@ -0,0 +1,53 @@ +using System.Collections; +using System.Collections.Generic; + +namespace EmbedIO.Testing +{ + partial class MockFileProvider + { + private sealed class MockDirectory : MockDirectoryEntry, IDictionary + { + readonly Dictionary _entries = new Dictionary(); + + public IEnumerator> GetEnumerator() => _entries.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable) _entries).GetEnumerator(); + + public void Add(KeyValuePair item) => (_entries as ICollection>).Add(item); + + public void Clear() => _entries.Clear(); + + public bool Contains(KeyValuePair item) => (_entries as ICollection>).Contains(item); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) => (_entries as ICollection>).CopyTo(array, arrayIndex); + + public bool Remove(KeyValuePair item) => (_entries as ICollection>).Remove(item); + + public int Count => _entries.Count; + + public bool IsReadOnly => false; + + public void Add(string key, MockDirectoryEntry value) => _entries.Add(key, value); + + public void Add(string key, byte[] data) => _entries.Add(key, new MockFile(data)); + + public void Add(string key, string data) => _entries.Add(key, new MockFile(data)); + + public bool ContainsKey(string key) => _entries.ContainsKey(key); + + public bool Remove(string key) => _entries.Remove(key); + + public bool TryGetValue(string key, out MockDirectoryEntry value) => _entries.TryGetValue(key, out value); + + public MockDirectoryEntry this[string key] + { + get => _entries[key]; + set => _entries[key] = value; + } + + public ICollection Keys => _entries.Keys; + + public ICollection Values => _entries.Values; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/MockFileProvider.MockDirectoryEntry.cs b/src/EmbedIO.Testing/MockFileProvider.MockDirectoryEntry.cs new file mode 100644 index 000000000..ded754f68 --- /dev/null +++ b/src/EmbedIO.Testing/MockFileProvider.MockDirectoryEntry.cs @@ -0,0 +1,19 @@ +using System; + +namespace EmbedIO.Testing +{ + partial class MockFileProvider + { + private abstract class MockDirectoryEntry + { + protected MockDirectoryEntry() + { + LastModifiedUtc = DateTime.UtcNow; + } + + public DateTime LastModifiedUtc { get; private set; } + + public void Touch() => LastModifiedUtc = DateTime.UtcNow; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/MockFileProvider.MockFile.cs b/src/EmbedIO.Testing/MockFileProvider.MockFile.cs new file mode 100644 index 000000000..e06b95f12 --- /dev/null +++ b/src/EmbedIO.Testing/MockFileProvider.MockFile.cs @@ -0,0 +1,39 @@ +using System; +using System.Text; + +namespace EmbedIO.Testing +{ + partial class MockFileProvider + { + private sealed class MockFile : MockDirectoryEntry + { + public MockFile(byte[] data) + { + Data = data ?? Array.Empty(); + } + + public MockFile(string text) + { + Data = text == null + ? Array.Empty() + : Encoding.UTF8.GetBytes(text); + } + + public byte[] Data { get; private set; } + + public void SetData(byte[] data) + { + Data = data ?? Array.Empty(); + Touch(); + } + + public void SetData(string text) + { + Data = text == null + ? Array.Empty() + : Encoding.UTF8.GetBytes(text); + Touch(); + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/MockFileProvider.cs b/src/EmbedIO.Testing/MockFileProvider.cs new file mode 100644 index 000000000..2e8b43009 --- /dev/null +++ b/src/EmbedIO.Testing/MockFileProvider.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using EmbedIO.Files; + +namespace EmbedIO.Testing +{ + /// + /// Provides an interface + /// that does not interfere with the file system. + /// This class simulates a small file system + /// with a root directory, a subdirectory, HTML index files, + /// and a data file filled with random bytes. + /// + /// + public sealed partial class MockFileProvider : IFileProvider + { + /// + /// The file name of HTML indexes. + /// + public const string IndexFileName = "index.html"; + + /// + /// The URL path to the HTML index of the root directory. + /// + public const string IndexUrlPath = "/index.html"; + + /// + /// The name of the subdirectory. + /// + public const string SubDirectoryName = "sub"; + + /// + /// The URL path to the subdirectory. + /// + public const string SubDirectoryUrlPath = "/sub"; + + /// + /// The URL path to the subdirectory HTML index. + /// + public const string SubDirectoryIndexUrlPath = "/sub/index.html"; + + /// + /// The URL path to a file containing random data. + /// + /// + /// + /// + public const string RandomDataUrlPath = "/random.dat"; + + private const string RandomDataPath = "random.dat"; + + private readonly Random _random; + private readonly MockFile _randomDataFile; + private readonly MockDirectory _root; + + /// + /// Initializes a new instance of the class. + /// + public MockFileProvider() + { + _random = new Random(); + _randomDataFile = new MockFile(CreateRandomData(10000)); + _root = new MockDirectory { + { "index.html", StockResource.GetBytes("index.html") }, + { "random.dat", _randomDataFile }, + { "sub", new MockDirectory { + { "index.html", StockResource.GetBytes("sub.index.html") }, + } }, + }; + + } + + /// + public event Action ResourceChanged; + + /// + public bool IsImmutable => false; + + /// + public void Start(CancellationToken cancellationToken) + { + } + + /// + public MappedResourceInfo MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider) + { + if (string.IsNullOrEmpty(urlPath)) + return null; + + if (!urlPath.StartsWith("/")) + return null; + + var path = urlPath.Substring(1); + var (name, entry) = FindEntry(path); + return GetResourceInfo(path, name, entry, mimeTypeProvider); + } + + /// + public Stream OpenFile(string path) + { + var (name, entry) = FindEntry(path); + return entry is MockFile file ? new MemoryStream(file.Data, false) : null; + } + + /// + public IEnumerable GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider) + { + var (name, entry) = FindEntry(path); + return entry is MockDirectory directory + ? directory.Select(pair => GetResourceInfo(AppendNameToPath(path, name), name, entry, mimeTypeProvider)) + : Enumerable.Empty(); + } + + /// + /// Gets the length of the random data file, + /// so it can be compared to the length of returned content. + /// + /// The length of the random data file. + /// + /// + /// + public int GetRandomDataLength() => _randomDataFile.Data.Length; + + /// + /// Gets the same random data that should be returned + /// in response to a request for the random data file. + /// + /// An array of bytes containing random data. + /// + /// + /// + public byte[] GetRandomData() => _randomDataFile.Data; + + /// + /// Creates and returns a new set of random data bytes. + /// After this method returns, requests for the random data file + /// should return the same bytes returned by this method. + /// + /// The length of the new random data. + /// An array of bytes containing the new random data. + public byte[] ChangeRandomData(int newLength) + { + var data = CreateRandomData(newLength); + _randomDataFile.SetData(data); + ResourceChanged?.Invoke(RandomDataPath); + return data; + } + + private byte[] CreateRandomData(int length) + { + var result = new byte[length]; + _random.NextBytes(result); + return result; + } + + private (string name, MockDirectoryEntry entry) FindEntry(string path) + { + if (path == null) + return default; + + if (path.Length == 0) + return (string.Empty, _root); + + var dir = _root; + var segments = path.Split('/'); + var lastIndex = segments.Length - 1; + var i = 0; + foreach (var segment in segments) + { + if (!dir.TryGetValue(segment, out var entry)) + return default; + + if (i == lastIndex && entry is MockFile file) + return (segment, file); + + if (!(entry is MockDirectory directory)) + return default; + + if (i == lastIndex) + return (segment, directory); + + dir = directory; + i++; + } + + return default; + } + + private MappedResourceInfo GetResourceInfo(string path, string name, MockDirectoryEntry entry, IMimeTypeProvider mimeTypeProvider) + { + switch (entry) + { + case MockFile file: + return MappedResourceInfo.ForFile( + path, + name, + file.LastModifiedUtc, + file.Data.Length, + mimeTypeProvider.GetMimeType(Path.GetExtension(name))); + case MockDirectory directory: + return MappedResourceInfo.ForDirectory(string.Empty, name, _root.LastModifiedUtc); + default: + return null; + } + } + + private static string AppendNameToPath(string path, string name) + => string.IsNullOrEmpty(path) ? name : $"{path}/{name}"; + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/MockMimeTypeProvider.cs b/src/EmbedIO.Testing/MockMimeTypeProvider.cs new file mode 100644 index 000000000..69c2b3697 --- /dev/null +++ b/src/EmbedIO.Testing/MockMimeTypeProvider.cs @@ -0,0 +1,29 @@ +namespace EmbedIO.Testing +{ + /// + /// Provides an interface + /// that associates all extensions to application/octet-stream + /// and never suggests any data compression preference. + /// + /// + public class MockMimeTypeProvider : IMimeTypeProvider + { + /// + /// + /// always returns + /// (application/octet-stream). + /// + public string GetMimeType(string extension) => MimeType.Default; + + /// + /// + /// always sets + /// to and returns , + /// + public bool TryDetermineCompression(string mimeType, out bool preferCompression) + { + preferCompression = default; + return false; + } + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/Resources/index.html b/src/EmbedIO.Testing/Resources/index.html similarity index 70% rename from test/Unosquare.Labs.EmbedIO.Tests/Resources/index.html rename to src/EmbedIO.Testing/Resources/index.html index 6d2268d96..86ff0d0cb 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/Resources/index.html +++ b/src/EmbedIO.Testing/Resources/index.html @@ -1,6 +1,5 @@  - - + diff --git a/test/Unosquare.Labs.EmbedIO.Tests/Resources/sub/index.html b/src/EmbedIO.Testing/Resources/sub/index.html similarity index 68% rename from test/Unosquare.Labs.EmbedIO.Tests/Resources/sub/index.html rename to src/EmbedIO.Testing/Resources/sub/index.html index a561f2551..9b953618f 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/Resources/sub/index.html +++ b/src/EmbedIO.Testing/Resources/sub/index.html @@ -1,6 +1,5 @@  - - + diff --git a/src/EmbedIO.Testing/StockResource.cs b/src/EmbedIO.Testing/StockResource.cs new file mode 100644 index 000000000..a569c82a6 --- /dev/null +++ b/src/EmbedIO.Testing/StockResource.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using EmbedIO.Utilities; + +namespace EmbedIO.Testing +{ + /// + /// Provides access to standard resources embedded in EmbedIO.Testing.dll. + /// Resources are organized in folders; access to a resource happens in a way + /// similar to URL paths, i.e. using slashes (/) as separators. + /// + public static class StockResource + { + private static readonly string Prefix = typeof(TestWebServer).Namespace + ".Resources."; + + private static readonly Assembly Assembly; + + static StockResource() + { + Assembly = Assembly.GetExecutingAssembly(); + } + + /// + /// Gets an enumeration of paths to all the defined stock resources. + /// + // NOTES TO CONTRIBUTORS: + // ===================== + // 1. Be careful to keep this array in sync with actual embedded resources. + // 2. There is currently no way to determine paths at runtime, + // because the distinction between slashes and dots gets lost + // when using Assembly.GetManifestResourceNames. + // 3. The property type is IEnumerable, so + // enumerating resources dynamically will not be a breaking change + // if someone finds a way to do it. + public static IEnumerable Paths { get; } = new[] { + "/index.html", + "/sub/index.html", + }; + + /// + /// Determines whether a stock resource exists. + /// + /// The path to the resource. + /// if the resource exists; + /// otherwise, . + public static bool Exists(string path) + => Assembly.GetManifestResourceNames().Contains(ConvertPath(path)); + + /// + /// Attempts to load a resource. + /// + /// The path to the resource. + /// When this method returns , + /// a representing the resource. + /// This parameter is passed uninitialized. + /// if the specified resource + /// has been loaded; otherwise, . + public static bool TryOpen(string path, out Stream stream) + { + stream = null; + if (string.IsNullOrEmpty(path)) + return false; + + try + { + stream = Assembly.GetManifestResourceStream(ConvertPath(path)); + return true; + } + catch (FileNotFoundException) + { + return false; + } + } + + /// + /// Loads the specified resource. + /// + /// The path to the resource. + /// A representing the resource, + /// or if the resource is not found. + /// is . + /// is an empty string. + /// is an empty string. + public static Stream Open(string path) + => Assembly.GetManifestResourceStream(ConvertPath(Validate.NotNullOrEmpty(nameof(path), path))); + + /// + /// Gets the length of a resource, expressed in bytes. + /// + /// The path to the resource. + /// The length of the specified resource. + /// is . + /// is an empty string. + /// is an empty string. + public static long GetLength(string path) + { + using (var stream = Open(path)) + { + return stream.Length; + } + } + + /// + /// Gets a resource as an array of bytes. + /// + /// The path to the resource. + /// An array of bytes containing the resource's contents. + /// is . + /// is an empty string. + /// is an empty string. + public static byte[] GetBytes(string path) + { + using (var stream = Open(path)) + { + var length = (int)stream.Length; + if (length == 0) + return Array.Empty(); + + var buffer = new byte[length]; + stream.Read(buffer, 0, length); + return buffer; + } + } + + /// + /// Gets a range of bytes from a resource's contents. + /// The range must be specified the same way as in HTTP Range headers, + /// i.e. with a starting offset and an inclusive upper bound; for example, + /// if is 200 and is 299 + /// then 100 bytes are returned, starting from the 201st byte (as indexes are 0-based). + /// + /// The path to the resource. + /// The starting offset of the range to return. + /// The inclusive upper bound of the range to return. + /// An array of bytes containing the specified range of the resource's contents, + /// or if the range is not valid. + /// is . + /// is an empty string. + /// is an empty string. + public static byte[] GetByteRange(string path, int start, int upperBound) + { + using (var stream = Open(path)) + { + var length = (int) stream.Length; + if (start >= length || upperBound < start || upperBound >= length) + return null; + + var rangeLength = upperBound - start + 1; + var buffer = new byte[rangeLength]; + stream.Position = start; + stream.Read(buffer, 0, rangeLength); + return buffer; + } + } + + /// + /// Gets a resource as text. + /// + /// The path to the resource. + /// The encoding to use to convert the resource's content + /// to a string. If is specified (the default), + /// UTF-8 will be used. + /// The specified resource as a . + /// is . + /// is an empty string. + /// is an empty string. + public static string GetText(string path, Encoding encoding = null) + { + using (var stream = Open(path)) + using (var reader = new StreamReader(stream, encoding ?? Encoding.UTF8, false, WebServer.StreamCopyBufferSize, true)) + { + return reader.ReadToEnd(); + } + } + + private static string ConvertPath(string path) + { + if (string.IsNullOrEmpty(path)) + return null; + + if (path[0] == '/') + path = path.Substring(1); + + return Prefix + path.Replace('/', '.'); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/TestHttpClient.cs b/src/EmbedIO.Testing/TestHttpClient.cs new file mode 100644 index 000000000..722e41e6b --- /dev/null +++ b/src/EmbedIO.Testing/TestHttpClient.cs @@ -0,0 +1,60 @@ +using System; +using System.Net; +using System.Net.Http; +using EmbedIO.Testing.Internal; +using EmbedIO.Utilities; + +namespace EmbedIO.Testing +{ + /// + /// A that can send requests + /// either to a interface, + /// or to a web server on the network. + /// + public sealed class TestHttpClient : HttpClient + { + private TestHttpClient(TestMessageHandler handler, string baseUrl) + : base(handler, true) + { + BaseAddress = new Uri(baseUrl); + CookieContainer = handler.CookieContainer; + } + + private TestHttpClient(HttpClientHandler handler, string baseUrl) + : base(handler, true) + { + BaseAddress = new Uri(baseUrl); + CookieContainer = handler.CookieContainer; + } + + /// + /// Gets the cookie container used to store server cookies. + /// + public CookieContainer CookieContainer { get; } + + /// + /// Creates a test client that communicates with the specified server. + /// + /// The server. + /// A newly-created . + /// + public static TestHttpClient Create(ITestWebServer server) + { + var handler = new TestMessageHandler(Validate.NotNull(nameof(server), server)); + return new TestHttpClient(handler, server.BaseUrl); + } + + /// + /// Creates a test client that communicates over the network + /// (typically with a ). + /// + /// The base URL of the server. + /// A newly-created . + /// + public static TestHttpClient Create(string baseUrl) + { + var handler = new HttpClientHandler(); + return new TestHttpClient(handler, baseUrl); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/TestWebServer.cs b/src/EmbedIO.Testing/TestWebServer.cs new file mode 100644 index 000000000..45d2efb3f --- /dev/null +++ b/src/EmbedIO.Testing/TestWebServer.cs @@ -0,0 +1,116 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Utilities; + +namespace EmbedIO.Testing +{ + /// + /// A Web server that does not actually communicate over the network; + /// instead, it manages an internal queue of requests that simulate + /// incoming connections. + /// Requests can be forwarded to the server using the instance + /// returned by the property. + /// + public class TestWebServer : WebServerBase, ITestWebServer + { + /// + /// The base URL that a , by default, simulates being bound to. + /// + public const string DefaultBaseUrl = "http://test.example.com:8080/"; + + private CancellationTokenSource _internalCancellationTokenSource; + + /// + /// Initializes a new instance of the class. + /// + /// + public TestWebServer(string baseUrl = DefaultBaseUrl) + { + BaseUrl = Validate.NotNullOrEmpty(nameof(baseUrl), baseUrl); + Client = TestHttpClient.Create(this); + } + + /// + /// Gets a that communicates with this server. + /// The returned client is already initialized with a base address, + /// so requests URLs may omit the scheme and host parts. + /// + public string BaseUrl { get; } + + /// + /// Gets a that communicates with this server. + /// The returned client is already initialized with a base address, + /// so requests URLs may omit the scheme and host parts. + /// + public TestHttpClient Client { get; } + + /// + /// Encapsulates the creation and use of a . + /// + /// A callback used to configure the server. + /// A callback used to pass requests to the server. + /// + /// is . + /// - or - + /// is . + /// + public static async Task UseAsync(Action configure, Func use) + { + Validate.NotNull(nameof(configure), configure); + Validate.NotNull(nameof(use), use); + + using (var server = new TestWebServer()) + { + configure(server); + using (var cancellationTokenSource = new CancellationTokenSource()) + { + server.Start(cancellationTokenSource.Token); + await use(server.Client).ConfigureAwait(false); + cancellationTokenSource.Cancel(); + } + } + } + + /// + protected override void Prepare(CancellationToken cancellationToken) + { + base.Prepare(cancellationToken); + + _internalCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + + /// + protected override Task ProcessRequestsAsync(CancellationToken cancellationToken) + { + // Since there's nothing to listen to, just wait for the server to be stopped. + _internalCancellationTokenSource.Token.WaitHandle.WaitOne(); + return Task.CompletedTask; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (_internalCancellationTokenSource != null) + { + if (!_internalCancellationTokenSource.IsCancellationRequested) + _internalCancellationTokenSource.Cancel(); + + _internalCancellationTokenSource.Dispose(); + } + } + + base.Dispose(disposing); + } + + /// + protected override void OnFatalException() + { + if (!(_internalCancellationTokenSource?.IsCancellationRequested ?? true)) + _internalCancellationTokenSource.Cancel(); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO.Testing/TestWebServerOptions.cs b/src/EmbedIO.Testing/TestWebServerOptions.cs new file mode 100644 index 000000000..36573fbb0 --- /dev/null +++ b/src/EmbedIO.Testing/TestWebServerOptions.cs @@ -0,0 +1,9 @@ +namespace EmbedIO.Testing +{ + /// + /// Contains options for configuring an instance of . + /// + public sealed class TestWebServerOptions : WebServerOptionsBase + { + } +} \ No newline at end of file diff --git a/src/EmbedIO/Actions/ActionModule.cs b/src/EmbedIO/Actions/ActionModule.cs new file mode 100644 index 000000000..8089d5198 --- /dev/null +++ b/src/EmbedIO/Actions/ActionModule.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.Utilities; + +namespace EmbedIO.Actions +{ + /// + /// A module that passes requests to a callback. + /// + /// + public class ActionModule : WebModuleBase + { + private readonly HttpVerbs _verb; + + private readonly RequestHandlerCallback _handler; + + /// + /// Initializes a new instance of the class. + /// + /// The base route. + /// The HTTP verb that will be served by this module. + /// The callback used to handle requests. + /// is . + /// + public ActionModule(string baseRoute, HttpVerbs verb, RequestHandlerCallback handler) + : base(baseRoute) + { + _verb = verb; + _handler = Validate.NotNull(nameof(handler), handler); + } + + /// + /// Initializes a new instance of the class. + /// + /// The handler. + public ActionModule(RequestHandlerCallback handler) + : this("/", HttpVerbs.Any, handler) + { + } + + /// + public override bool IsFinalHandler => false; + + /// + protected override async Task OnRequestAsync(IHttpContext context) + { + if (_verb != HttpVerbs.Any && context.Request.HttpVerb != _verb) + return; + + await _handler(context).ConfigureAwait(false); + context.SetHandled(); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Actions/RedirectModule.cs b/src/EmbedIO/Actions/RedirectModule.cs new file mode 100644 index 000000000..8e3caf6e3 --- /dev/null +++ b/src/EmbedIO/Actions/RedirectModule.cs @@ -0,0 +1,99 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using EmbedIO.Utilities; + +namespace EmbedIO.Actions +{ + /// + /// A module that redirects requests. + /// + /// + public class RedirectModule : WebModuleBase + { + private readonly Func _shouldRedirect; + + /// + /// Initializes a new instance of the class + /// that will redirect all served requests. + /// + /// The base route. + /// The redirect URL. + /// The response status code; default is 302 - Found. + /// is . + /// + /// is not a valid URL. + /// - or - + /// is not a redirection (3xx) status code. + /// + /// + public RedirectModule(string baseRoute, string redirectUrl, HttpStatusCode statusCode = HttpStatusCode.Found) + : this(baseRoute, redirectUrl, null, statusCode, false) + { + } + + /// + /// Initializes a new instance of the class + /// that will redirect all requests for which the callback + /// returns . + /// + /// The base route. + /// The redirect URL. + /// A callback function that returns + /// if a request must be redirected. + /// The response status code; default is 302 - Found. + /// + /// is . + /// - or - + /// is . + /// + /// + /// is not a valid URL. + /// - or - + /// is not a redirection (3xx) status code. + /// + /// + public RedirectModule(string baseRoute, string redirectUrl, Func shouldRedirect, HttpStatusCode statusCode = HttpStatusCode.Found) + : this(baseRoute, redirectUrl, shouldRedirect, statusCode, true) + { + } + + private RedirectModule(string baseRoute, string redirectUrl, Func shouldRedirect, HttpStatusCode statusCode, bool useCallback) + : base(baseRoute) + { + RedirectUrl = Validate.Url(nameof(redirectUrl), redirectUrl); + + var status = (int)statusCode; + if (status < 300 || status > 399) + throw new ArgumentException("Status code does not imply a redirection.", nameof(statusCode)); + + StatusCode = statusCode; + _shouldRedirect = useCallback ? Validate.NotNull(nameof(shouldRedirect), shouldRedirect) : null; + } + + /// + public override bool IsFinalHandler => false; + + /// + /// Gets the redirect URL. + /// + public string RedirectUrl { get; } + + /// + /// Gets the response status code. + /// + public HttpStatusCode StatusCode { get; } + + /// + protected override Task OnRequestAsync(IHttpContext context) + { + if (_shouldRedirect?.Invoke(context) ?? true) + { + context.Redirect(RedirectUrl, (int)StatusCode); + context.SetHandled(); + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Authentication/BasicAuthenticationModule.cs b/src/EmbedIO/Authentication/BasicAuthenticationModule.cs new file mode 100644 index 000000000..2e025c341 --- /dev/null +++ b/src/EmbedIO/Authentication/BasicAuthenticationModule.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace EmbedIO.Authentication +{ + /// + /// Simple HTTP basic authentication module that stores credentials + /// in a . + /// + public class BasicAuthenticationModule : BasicAuthenticationModuleBase + { + /// + /// Initializes a new instance of the class. + /// + /// The base route. + /// The authentication realm. + /// + /// If is or the empty string, + /// the Realm property will be set equal to + /// BaseRoute. + /// + public BasicAuthenticationModule(string baseRoute, string realm = null) + : base(baseRoute, realm) + { + } + + /// + /// Gets a dictionary of valid user names and passwords. + /// + /// + /// The accounts. + /// + public ConcurrentDictionary Accounts { get; } = new ConcurrentDictionary(StringComparer.InvariantCulture); + + /// + protected override Task VerifyCredentialsAsync(string path, string userName, string password, CancellationToken cancellationToken) + => Task.FromResult(VerifyCredentialsInternal(userName, password)); + + private bool VerifyCredentialsInternal(string userName, string password) + => userName != null + && Accounts.TryGetValue(userName, out var storedPassword) + && string.Equals(password, storedPassword, StringComparison.Ordinal); + } +} diff --git a/src/EmbedIO/Authentication/BasicAuthenticationModuleBase.cs b/src/EmbedIO/Authentication/BasicAuthenticationModuleBase.cs new file mode 100644 index 000000000..60f489aac --- /dev/null +++ b/src/EmbedIO/Authentication/BasicAuthenticationModuleBase.cs @@ -0,0 +1,103 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace EmbedIO.Authentication +{ + /// + /// Implements HTTP basic authentication. + /// + public abstract class BasicAuthenticationModuleBase : WebModuleBase + { + private readonly string _wwwAuthenticateHeaderValue; + + /// + /// Initializes a new instance of the class. + /// + /// The base URL path. + /// The authentication realm. + /// + /// If is or the empty string, + /// the property will be set equal to + /// BaseRoute. + /// + protected BasicAuthenticationModuleBase(string baseRoute, string realm) + : base(baseRoute) + { + Realm = string.IsNullOrEmpty(realm) ? BaseRoute : realm; + + _wwwAuthenticateHeaderValue = $"Basic realm=\"{Realm}\" charset=UTF-8"; + } + + /// + public sealed override bool IsFinalHandler => false; + + /// + /// Gets the authentication realm. + /// + public string Realm { get; } + + /// + protected sealed override async Task OnRequestAsync(IHttpContext context) + { + async Task IsAuthenticatedAsync() + { + try + { + var (userName, password) = GetCredentials(context.Request); + return await VerifyCredentialsAsync(context.RequestedPath, userName, password, context.CancellationToken) + .ConfigureAwait(false); + } + catch (FormatException) + { + // Credentials were not formatted correctly. + return false; + } + } + + if (!await IsAuthenticatedAsync().ConfigureAwait(false)) + throw HttpException.Unauthorized(); + + context.Response.Headers.Set(HttpHeaderNames.WWWAuthenticate, _wwwAuthenticateHeaderValue); + } + + /// + /// Verifies the credentials given in the Authentication request header. + /// + /// The URL path requested by the client. Note that this is relative + /// to the module's BaseRoute. + /// The user name, or if none has been given. + /// The password, or if none has been given. + /// A use to cancel the operation. + /// A whose result will be if the given credentials + /// are valid, if they are not. + protected abstract Task VerifyCredentialsAsync(string path, string userName, string password, CancellationToken cancellationToken); + + private static (string UserName, string Password) GetCredentials(IHttpRequest request) + { + var authHeader = request.Headers[HttpHeaderNames.Authorization]; + + if (authHeader == null) + return default; + + if (!authHeader.StartsWith("basic ", StringComparison.OrdinalIgnoreCase)) + return default; + + string credentials; + try + { + credentials = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Substring(6).Trim())); + } + catch (FormatException) + { + return default; + } + + var separatorPos = credentials.IndexOf(':'); + return separatorPos < 0 + ? (credentials, string.Empty) + : (credentials.Substring(0, separatorPos), credentials.Substring(separatorPos + 1)); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Authentication/BasicAuthenticationModuleExtensions.cs b/src/EmbedIO/Authentication/BasicAuthenticationModuleExtensions.cs new file mode 100644 index 000000000..448ecec7b --- /dev/null +++ b/src/EmbedIO/Authentication/BasicAuthenticationModuleExtensions.cs @@ -0,0 +1,34 @@ +using System; + +namespace EmbedIO.Authentication +{ + /// + /// Provides extension methods for . + /// + public static class BasicAuthenticationModuleExtensions + { + /// + /// Adds a username and password to the Accounts dictionary. + /// + /// The on which this method is called. + /// The user name. + /// The password. + /// , with the user name and password added. + /// is . + /// is . + /// + /// The Accounts dictionary already contains + /// the maximum number of elements (MaxValue). + /// + /// + /// If a account already exists, + /// its password is replaced with . + /// + public static BasicAuthenticationModule WithAccount(this BasicAuthenticationModule @this, string userName, string password) + { + @this.Accounts.AddOrUpdate(userName, password, (_, __) => password); + + return @this; + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Constants/CompressionMethod.cs b/src/EmbedIO/CompressionMethod.cs similarity index 80% rename from src/Unosquare.Labs.EmbedIO/Constants/CompressionMethod.cs rename to src/EmbedIO/CompressionMethod.cs index a295778cb..82f21cffd 100644 --- a/src/Unosquare.Labs.EmbedIO/Constants/CompressionMethod.cs +++ b/src/EmbedIO/CompressionMethod.cs @@ -1,4 +1,4 @@ -namespace Unosquare.Labs.EmbedIO.Constants +namespace EmbedIO { /// /// 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: + /// + /// + /// + /// + /// The return type of a compatible handler may be either + /// or . + /// A compatible handler, in order to be added to a , + /// must have one or more Route attributes. + /// The same handler will be added once for each such attribute, either declared on the handler, + /// or inherited (if the handler is a virtual method). + /// This method behaves according to the type of the + /// parameter: + /// + /// if is a , all public static methods of + /// the type (either declared on the same type or inherited) that are compatible handlers will be added + /// to the collection; + /// if is an , all public static methods of + /// each exported type of the assembly (either declared on the same type or inherited) that are compatible handlers will be added + /// to the collection; + /// if is a referring to a compatible handler, + /// it will be added to the collection; + /// if is a whose Method + /// refers to a compatible handler, that method will be added to the collection; + /// if is none of the above, all public instance methods of + /// its type (either declared on the same type or inherited) that are compatible handlers will be bound to + /// and added to the collection. + /// + /// + /// Where to look for compatible handlers. See the Summary section for more information. + /// + /// The number of handlers that were added to the collection. + /// Note that methods with multiple Route attributes + /// will count as one for each attribute. + /// + /// is . + public int AddFrom(object target) + { + switch (Validate.NotNull(nameof(target), target)) + { + case Type type: + return AddFrom(null, type); + case Assembly assembly: + return assembly.GetExportedTypes().Sum(t => AddFrom(null, t)); + case MethodInfo method: + return method.IsStatic ? Add(null, method) : 0; + case Delegate callback: + return Add(callback.Target, callback.Method); + default: + return AddFrom(target, target.GetType()); + } + } + + /// + protected override RouteVerbResolver CreateResolver(string route) => new RouteVerbResolver(route); + + /// + protected override void OnResolverCalled(IHttpContext context, RouteVerbResolver resolver, RouteResolutionResult result) + => $"[{context.Id}] Route {resolver.Route} : {result}".Trace(_logSource); + + private static bool IsHandlerCompatibleMethod(MethodInfo method, out bool isSynchronous) + { + isSynchronous = false; + var returnType = method.ReturnType; + if (returnType == typeof(void)) + { + isSynchronous = true; + } + else if (returnType != typeof(Task)) + { + return false; + } + + var parameters = method.GetParameters(); + return parameters.Length == 2 + && parameters[0].ParameterType.IsAssignableFrom(typeof(IHttpContext)) + && parameters[1].ParameterType.IsAssignableFrom(typeof(RouteMatch)); + } + + // Call Add with all suitable methods of a Type, return sum of results. + private int AddFrom(object target, Type type) + => type.GetMethods(target == null + ? BindingFlags.Public | BindingFlags.Static + : BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.IsPublic + && !method.IsAbstract + && !method.ContainsGenericParameters) + .Sum(m => Add(target, m)); + + private int Add(object target, MethodInfo method) + { + if (!IsHandlerCompatibleMethod(method, out var isSynchronous)) + return 0; + + var attributes = method.GetCustomAttributes(typeof(RouteAttribute), true).OfType().ToArray(); + if (attributes.Length == 0) + return 0; + + var parameters = new[] { + Expression.Parameter(typeof(IHttpContext), "context"), + Expression.Parameter(typeof(RouteMatch), "route"), + }; + + Expression body = Expression.Call(Expression.Constant(target), method, parameters.Cast()); + if (isSynchronous) + { + // Convert void to Task by evaluating Task.CompletedTask + body = Expression.Block(typeof(Task), body, Expression.Constant(Task.CompletedTask)); + } + + var handler = Expression.Lambda(body, parameters).Compile(); + foreach (var attribute in attributes) + { + Add(attribute.Verb, attribute.Route, handler); + } + + return attributes.Length; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Routing/RoutingModule.cs b/src/EmbedIO/Routing/RoutingModule.cs new file mode 100644 index 000000000..50f12ad85 --- /dev/null +++ b/src/EmbedIO/Routing/RoutingModule.cs @@ -0,0 +1,64 @@ +using System; + +namespace EmbedIO.Routing +{ + /// + /// A module that handles requests by resolving route / method pairs associated with handlers. + /// + /// + public class RoutingModule : RoutingModuleBase + { + /// + /// + /// Initializes a new instance of the class. + /// + public RoutingModule(string baseRoute) + : base(baseRoute) + { + } + + /// + /// Associates a HTTP method and a route to a handler. + /// + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public void Add(HttpVerbs verb, string route, RouteHandlerCallback handler) + => AddHandler(verb, route, handler); + + /// + /// Associates a HTTP method and a route to a synchronous handler. + /// + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public void Add(HttpVerbs verb, string route, SyncRouteHandlerCallback handler) + => AddHandler(verb, route, handler); + + /// + /// Adds handlers, associating them with HTTP method / route pairs by means + /// of Route attributes. + /// See for further information. + /// + /// Where to look for compatible handlers. + /// The number of handlers that were added. + /// is . + public int AddFrom(object target) => AddHandlersFrom(target); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Routing/RoutingModuleBase.cs b/src/EmbedIO/Routing/RoutingModuleBase.cs new file mode 100644 index 000000000..1e7434704 --- /dev/null +++ b/src/EmbedIO/Routing/RoutingModuleBase.cs @@ -0,0 +1,336 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.Internal; + +namespace EmbedIO.Routing +{ + /// + /// Base class for modules that handle requests by resolving route / method pairs associated with handlers. + /// + /// + public abstract class RoutingModuleBase : WebModuleBase + { + private readonly RouteVerbResolverCollection _resolvers = new RouteVerbResolverCollection(nameof(RoutingModuleBase)); + + /// + /// + /// Initializes a new instance of the class. + /// + protected RoutingModuleBase(string baseRoute) + : base(baseRoute) + { + } + + /// + public override bool IsFinalHandler => true; + + /// + protected override async Task OnRequestAsync(IHttpContext context) + { + var result = await _resolvers.ResolveAsync(context).ConfigureAwait(false); + switch (result) + { + case RouteResolutionResult.RouteNotMatched: + case RouteResolutionResult.NoHandlerSuccessful: + await OnPathNotFoundAsync(context).ConfigureAwait(false); + break; + case RouteResolutionResult.NoHandlerSelected: + await OnMethodNotAllowedAsync(context).ConfigureAwait(false); + break; + case RouteResolutionResult.Success: + return; + default: + SelfCheck.Fail($"Internal error: unknown route resolution result {result}."); + return; + } + } + + /// + /// Associates a HTTP method and a route to a handler. + /// + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void AddHandler(HttpVerbs verb, string route, RouteHandlerCallback handler) + => _resolvers.Add(verb, route, handler); + + /// + /// Associates a HTTP method and a route to a synchronous handler. + /// + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void AddHandler(HttpVerbs verb, string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(verb, route, handler); + + /// + /// Adds handlers, associating them with HTTP method / route pairs by means + /// of Route attributes. + /// See for further information. + /// + /// Where to look for compatible handlers. + /// The number of handlers that were added. + /// is . + protected int AddHandlersFrom(object target) + => _resolvers.AddFrom(target); + + /// + /// Associates all requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnAny(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Any, route, handler); + + /// + /// Associates all requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnAny(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Any, route, handler); + + /// + /// Associates DELETE requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnDelete(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Delete, route, handler); + + /// + /// Associates DELETE requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnDelete(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Delete, route, handler); + + /// + /// Associates GET requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnGet(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Get, route, handler); + + /// + /// Associates GET requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnGet(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Get, route, handler); + + /// + /// Associates HEAD requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnHead(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Head, route, handler); + + /// + /// Associates HEAD requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnHead(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Head, route, handler); + + /// + /// Associates OPTIONS requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnOptions(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Options, route, handler); + + /// + /// Associates OPTIONS requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnOptions(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Options, route, handler); + + /// + /// Associates PATCH requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPatch(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Patch, route, handler); + + /// + /// Associates PATCH requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPatch(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Patch, route, handler); + + /// + /// Associates POST requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPost(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Post, route, handler); + + /// + /// Associates POST requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPost(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Post, route, handler); + + /// + /// Associates PUT requests matching a route to a handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPut(string route, RouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Put, route, handler); + + /// + /// Associates PUT requests matching a route to a synchronous handler. + /// + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + protected void OnPut(string route, SyncRouteHandlerCallback handler) + => _resolvers.Add(HttpVerbs.Put, route, handler); + + /// + /// Called when no route is matched for the requested URL path. + /// The default behavior is to send an empty 404 Not Found response. + /// + /// The context of the request being handled. + /// A representing the ongoing operation. + protected virtual Task OnPathNotFoundAsync(IHttpContext context) + => throw HttpException.NotFound(); + + /// + /// Called when at least one route is matched for the requested URL path, + /// but none of them is associated with the HTTP method of the request. + /// The default behavior is to send an empty 405 Method Not Allowed response. + /// + /// The context of the request being handled. + /// A representing the ongoing operation. + protected virtual Task OnMethodNotAllowedAsync(IHttpContext context) + => throw HttpException.MethodNotAllowed(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Routing/RoutingModuleExtensions.cs b/src/EmbedIO/Routing/RoutingModuleExtensions.cs new file mode 100644 index 000000000..3d50d4049 --- /dev/null +++ b/src/EmbedIO/Routing/RoutingModuleExtensions.cs @@ -0,0 +1,394 @@ +using System; + +namespace EmbedIO.Routing +{ + /// + /// Provides extension methods for . + /// + public static class RoutingModuleExtensions + { + /// + /// Adds a handler to a . + /// + /// The on which this method is called. + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + /// + public static RoutingModule Handle(this RoutingModule @this, HttpVerbs verb, string route, RouteHandlerCallback handler) + { + @this.Add(verb, route, handler); + return @this; + } + + /// + /// Adds a synchronous handler to a . + /// + /// The on which this method is called. + /// A constant representing the HTTP method + /// to associate with , or + /// if can handle all HTTP methods. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + /// + public static RoutingModule Handle(this RoutingModule @this, HttpVerbs verb, string route, SyncRouteHandlerCallback handler) + { + @this.Add(verb, route, handler); + return @this; + } + + /// + /// Adds handlers, associating them with HTTP method / route pairs by means + /// of Route attributes. + /// See for further information. + /// + /// The on which this method is called. + /// Where to look for compatible handlers. + /// with handlers added. + /// is . + /// is . + public static RoutingModule WithHandlersFrom(this RoutingModule @this, object target) + { + @this.AddFrom(target); + return @this; + } + + /// + /// Associates all requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnAny(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Any, route, handler); + return @this; + } + + /// + /// Associates all requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnAny(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Any, route, handler); + return @this; + } + + /// + /// Associates DELETE requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnDelete(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Delete, route, handler); + return @this; + } + + /// + /// Associates DELETE requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnDelete(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Delete, route, handler); + return @this; + } + + /// + /// Associates GET requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnGet(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Get, route, handler); + return @this; + } + + /// + /// Associates GET requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnGet(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Get, route, handler); + return @this; + } + + /// + /// Associates HEAD requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnHead(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Head, route, handler); + return @this; + } + + /// + /// Associates HEAD requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnHead(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Head, route, handler); + return @this; + } + + /// + /// Associates OPTIONS requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnOptions(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Options, route, handler); + return @this; + } + + /// + /// Associates OPTIONS requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnOptions(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Options, route, handler); + return @this; + } + + /// + /// Associates PATCH requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPatch(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Patch, route, handler); + return @this; + } + + /// + /// Associates PATCH requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPatch(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Patch, route, handler); + return @this; + } + + /// + /// Associates POST requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPost(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Post, route, handler); + return @this; + } + + /// + /// Associates POST requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPost(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Post, route, handler); + return @this; + } + + /// + /// Associates PUT requests matching a route to a handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPut(this RoutingModule @this, string route, RouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Put, route, handler); + return @this; + } + + /// + /// Associates PUT requests matching a route to a synchronous handler. + /// + /// The on which this method is called. + /// The route to match URL paths against. + /// A callback used to handle matching contexts. + /// with the handler added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// is not a valid route. + public static RoutingModule OnPut(this RoutingModule @this, string route, SyncRouteHandlerCallback handler) + { + @this.Add(HttpVerbs.Put, route, handler); + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Routing/SyncRouteHandlerCallback.cs b/src/EmbedIO/Routing/SyncRouteHandlerCallback.cs new file mode 100644 index 000000000..97e296da6 --- /dev/null +++ b/src/EmbedIO/Routing/SyncRouteHandlerCallback.cs @@ -0,0 +1,10 @@ +namespace EmbedIO.Routing +{ + /// + /// Base class for callbacks used to handle routed requests synchronously. + /// + /// A interface representing the context of the request. + /// The matched route. + /// + public delegate void SyncRouteHandlerCallback(IHttpContext context, RouteMatch route); +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/ISession.cs b/src/EmbedIO/Sessions/ISession.cs new file mode 100644 index 000000000..eb27cefb1 --- /dev/null +++ b/src/EmbedIO/Sessions/ISession.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Swan.Collections; + +namespace EmbedIO.Sessions +{ + /// + /// Represents a session. + /// + public interface ISession + { + /// + /// A unique identifier for the session. + /// + /// The unique identifier for this session. + /// + /// + string Id { get; } + + /// + /// Gets the time interval, starting from , + /// after which the session expires. + /// + /// The expiration time. + TimeSpan Duration { get; } + + /// + /// Gets the UTC date and time of last activity on the session. + /// + /// + /// The UTC date and time of last activity on the session. + /// + DateTime LastActivity { get; } + + /// + /// Gets the number of key/value pairs contained in a session. + /// + /// + /// The number of key/value pairs contained in the object that implements . + /// + int Count { get; } + + /// + /// Gets a value that indicates whether a session is empty. + /// + /// + /// if the object that implements is empty, + /// i.e. contains no key / value pairs; otherwise, . + /// + bool IsEmpty { get; } + + /// + /// Gets or sets the value associated with the specified key. + /// Note that a session does not store null values; therefore, setting this property to + /// has the same effect as removing from the dictionary. + /// + /// + /// The value associated with the specified key, if + /// is found in the dictionary; otherwise, . + /// + /// The key of the value to get or set. + /// is . + object this[string key] { get; set; } + + /// + /// Removes all keys and values from a session. + /// + void Clear(); + + /// + /// Determines whether a session contains an element with the specified key. + /// + /// The key to locate in the object that implements . + /// + /// if the object that implements contains an element with the key; + /// otherwise, . + /// + /// is . + bool ContainsKey(string key); + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get. + /// When this method returns, the value associated with the specified , + /// if the key is found; otherwise, . This parameter is passed uninitialized. + /// if the object that implements + /// contains an element with the specified key; otherwise, . + /// is . + bool TryGetValue(string key, out object value); + + /// + /// Attempts to remove and return the value that has the specified key from a session. + /// + /// The key of the element to remove and return. + /// When this method returns, the value removed from the object that implements , + /// if the key is found; otherwise, . This parameter is passed uninitialized. + /// if the value was removed successfully; otherwise, . + /// is . + bool TryRemove(string key, out object value); + + /// + /// Takes and returns a snapshot of the contents of a session at the time of calling. + /// + /// An IReadOnlyList<KeyValuePair<string,object>> interface + /// containing an immutable copy of the session data as it was at the time of calling this method. + /// + /// The objects contained in the session data are copied by reference, not cloned; therefore + /// you should be aware that their state may change even after the snapshot is taken. + /// + IReadOnlyList> TakeSnapshot(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/ISessionManager.cs b/src/EmbedIO/Sessions/ISessionManager.cs new file mode 100644 index 000000000..ccee33d22 --- /dev/null +++ b/src/EmbedIO/Sessions/ISessionManager.cs @@ -0,0 +1,46 @@ +using System.Threading; + +namespace EmbedIO.Sessions +{ + /// + /// Represents a session manager, which is in charge of managing session objects + /// and their association to HTTP contexts. + /// + public interface ISessionManager + { + /// + /// Signals a session manager that the web server is starting. + /// + /// The cancellation token used to stop the web server. + void Start(CancellationToken cancellationToken); + + /// + /// Returns the session associated with a . + /// If a session ID can be retrieved for the context and stored session data + /// are available, the returned will contain those data; + /// otherwise, a new session is created and its ID is stored in the response + /// to be retrieved by subsequent requests. + /// + /// The HTTP context. + /// A interface. + ISession Create(IHttpContext context); + + /// + /// Deletes the session (if any) associated with the specified context + /// and removes the session's ID from the context. + /// + /// The HTTP context. + /// The unique ID of the session. + /// + void Delete(IHttpContext context, string id); + + /// + /// Called by a session proxy when a session has been obtained + /// for a and the context is closed, + /// even if the session was subsequently deleted. + /// This method can be used to save session data to a storage medium. + /// + /// The for which a session was obtained. + void OnContextClose(IHttpContext context); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/ISessionProxy.cs b/src/EmbedIO/Sessions/ISessionProxy.cs new file mode 100644 index 000000000..299b8ad32 --- /dev/null +++ b/src/EmbedIO/Sessions/ISessionProxy.cs @@ -0,0 +1,33 @@ +namespace EmbedIO.Sessions +{ + /// + /// Represents a session proxy, i.e. an object that provides + /// the same interface as a session object, plus a basic interface + /// to a session manager. + /// + /// + /// A session proxy can be used just as if it were a session object. + /// A session is automatically created wherever its data are accessed. + /// + /// + public interface ISessionProxy : ISession + { + /// + /// Gets a value indicating whether a session exists for the current context. + /// + /// + /// if a session exists; otherwise, . + /// + bool Exists { get; } + + /// + /// Deletes the session for the current context. + /// + void Delete(); + + /// + /// Deletes the session for the current context and creates a new one. + /// + void Regenerate(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/LocalSessionManager.SessionImpl.cs b/src/EmbedIO/Sessions/LocalSessionManager.SessionImpl.cs new file mode 100644 index 000000000..81dbc905f --- /dev/null +++ b/src/EmbedIO/Sessions/LocalSessionManager.SessionImpl.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EmbedIO.Utilities; +using Swan.Collections; + +namespace EmbedIO.Sessions +{ + partial class LocalSessionManager + { + private class SessionImpl : ISession + { + private readonly DataDictionary _data = new DataDictionary(Session.KeyComparer); + + private int _usageCount; + + public SessionImpl(string id, TimeSpan duration) + { + Id = Validate.NotNullOrEmpty(nameof(id), id); + Duration = duration; + LastActivity = DateTime.UtcNow; + _usageCount = 1; + } + + public string Id { get; } + + public TimeSpan Duration { get; } + + public DateTime LastActivity { get; private set; } + + public int Count + { + get + { + lock (_data) + { + return _data.Count; + } + } + } + + public bool IsEmpty + { + get + { + lock (_data) + { + return _data.IsEmpty; + } + } + } + + public object this[string key] + { + get + { + lock (_data) + { + return _data[key]; + } + } + set + { + lock (_data) + { + _data[key] = value; + } + } + } + + public void Clear() + { + lock (_data) + { + _data.Clear(); + } + } + + public bool ContainsKey(string key) + { + lock (_data) + { + return _data.ContainsKey(key); + } + } + + public bool TryRemove(string key, out object value) + { + lock (_data) + { + return _data.TryRemove(key, out value); + } + } + + public IReadOnlyList> TakeSnapshot() + { + lock (_data) + { + return _data.ToArray(); + } + } + + public bool TryGetValue(string key, out object value) + { + lock (_data) + { + return _data.TryGetValue(key, out value); + } + } + + internal void BeginUse() + { + lock (_data) + { + _usageCount++; + LastActivity = DateTime.UtcNow; + } + } + + internal void EndUse(Action unregister) + { + lock (_data) + { + --_usageCount; + UnregisterIfNeededCore(unregister); + } + } + + internal void UnregisterIfNeeded(Action unregister) + { + lock (_data) + { + UnregisterIfNeededCore(unregister); + } + } + + private void UnregisterIfNeededCore(Action unregister) + { + if (_usageCount < 1 && (IsEmpty || DateTime.UtcNow > LastActivity + Duration)) + unregister(); + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/LocalSessionManager.cs b/src/EmbedIO/Sessions/LocalSessionManager.cs new file mode 100644 index 000000000..fc66ea44c --- /dev/null +++ b/src/EmbedIO/Sessions/LocalSessionManager.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Utilities; + +namespace EmbedIO.Sessions +{ + /// + /// A simple session manager to handle in-memory sessions. + /// Not for intensive use or for distributed applications. + /// + public partial class LocalSessionManager : ISessionManager + { + /// + /// The default name for session cookies, i.e. "__session". + /// + public const string DefaultCookieName = "__session"; + + /// + /// The default path for session cookies, i.e. the empty string. + /// + public const string DefaultCookiePath = ""; + + /// + /// The default HTTP-only flag for session cookies, i.e. . + /// + public const bool DefaultCookieHttpOnly = true; + + /// + /// The default duration for session cookies, i.e. . + /// + public static readonly TimeSpan DefaultCookieDuration = TimeSpan.Zero; + + /// + /// The default duration for sessions, i.e. 30 minutes. + /// + public static readonly TimeSpan DefaultSessionDuration = TimeSpan.FromMinutes(30); + + /// + /// The default interval between automatic purges of expired and empty sessions, i.e. 30 seconds. + /// + public static readonly TimeSpan DefaultPurgeInterval = TimeSpan.FromSeconds(30); + + private readonly ConcurrentDictionary _sessions = + new ConcurrentDictionary(Session.KeyComparer); + + private string _cookieName = DefaultCookieName; + + private string _cookiePath = DefaultCookiePath; + + private TimeSpan _cookieDuration = DefaultCookieDuration; + + private bool _cookieHttpOnly = DefaultCookieHttpOnly; + + private TimeSpan _sessionDuration = DefaultSessionDuration; + + private TimeSpan _purgeInterval = DefaultPurgeInterval; + + /// + /// Initializes a new instance of the class + /// with default values for all properties. + /// + /// + /// + /// + /// + /// + /// + public LocalSessionManager() + { + } + + /// + /// Gets or sets the duration of newly-created sessions. + /// + /// This property is being set after calling + /// the method. + /// + public TimeSpan SessionDuration + { + get => _sessionDuration; + set + { + EnsureConfigurationNotLocked(); + _sessionDuration = value; + } + } + + /// + /// Gets or sets the interval between purges of expired sessions. + /// + /// This property is being set after calling + /// the method. + /// + public TimeSpan PurgeInterval + { + get => _purgeInterval; + set + { + EnsureConfigurationNotLocked(); + _purgeInterval = value; + } + } + + /// + /// Gets or sets the name for session cookies. + /// + /// This property is being set after calling + /// the method. + /// This property is being set to . + /// This property is being set and the provided value + /// is not a valid URL path. + /// + public string CookieName + { + get => _cookieName; + set + { + EnsureConfigurationNotLocked(); + _cookieName = Validate.Rfc2616Token(nameof(value), value); + } + } + + /// + /// Gets or sets the path for session cookies. + /// + /// This property is being set after calling + /// the method. + /// This property is being set to . + /// This property is being set and the provided value + /// is not a valid URL path. + /// + public string CookiePath + { + get => _cookiePath; + set + { + EnsureConfigurationNotLocked(); + _cookiePath = Validate.UrlPath(nameof(value), value, true); + } + } + + /// + /// Gets or sets the duration of session cookies. + /// + /// This property is being set after calling + /// the method. + /// + public TimeSpan CookieDuration + { + get => _cookieDuration; + set + { + EnsureConfigurationNotLocked(); + _cookieDuration = value; + } + } + + /// + /// Gets or sets a value indicating whether session cookies are hidden from Javascript code running on a user agent. + /// + /// This property is being set after calling + /// the method. + /// + public bool CookieHttpOnly + { + get => _cookieHttpOnly; + set + { + EnsureConfigurationNotLocked(); + _cookieHttpOnly = value; + } + } + + private bool ConfigurationLocked { get; set; } + + /// + public void Start(CancellationToken cancellationToken) + { + ConfigurationLocked = true; + + Task.Run(async () => + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + PurgeExpiredAndEmptySessions(); + await Task.Delay(PurgeInterval, cancellationToken).ConfigureAwait(false); + } + } + catch (TaskCanceledException) + { + // ignore + } + }, cancellationToken); + } + + /// + public ISession Create(IHttpContext context) + { + var id = context.Request.Cookies.FirstOrDefault(IsSessionCookie)?.Value.Trim(); + + SessionImpl session; + lock (_sessions) + { + if (!string.IsNullOrEmpty(id) && _sessions.TryGetValue(id, out session)) + { + session.BeginUse(); + } + else + { + id = UniqueIdGenerator.GetNext(); + session = new SessionImpl(id, SessionDuration); + _sessions.TryAdd(id, session); + } + } + + context.Request.Cookies.Add(BuildSessionCookie(id)); + context.Response.Cookies.Add(BuildSessionCookie(id)); + return session; + } + + /// + public void Delete(IHttpContext context, string id) + { + lock (_sessions) + { + if (_sessions.TryGetValue(id, out var session)) + { + session.EndUse(() => _sessions.TryRemove(id, out _)); + } + } + + context.Request.Cookies.Add(BuildSessionCookie(string.Empty)); + context.Response.Cookies.Add(BuildSessionCookie(string.Empty)); + } + + /// + public void OnContextClose(IHttpContext context) + { + if (!context.Session.Exists) + return; + + var id = context.Session.Id; + lock (_sessions) + { + if (_sessions.TryGetValue(id, out var session)) + { + session.EndUse(() => _sessions.TryRemove(id, out _)); + } + } + } + + private void EnsureConfigurationNotLocked() + { + if (ConfigurationLocked) + throw new InvalidOperationException($"Cannot configure a {nameof(LocalSessionManager)} once it has been started."); + } + + private bool IsSessionCookie(Cookie cookie) + => cookie.Name.Equals(CookieName, StringComparison.OrdinalIgnoreCase) + && !cookie.Expired; + + private Cookie BuildSessionCookie(string id) + { + var cookie = new Cookie(CookieName, id, CookiePath) + { + HttpOnly = CookieHttpOnly, + }; + + if (CookieDuration > TimeSpan.Zero) + { + cookie.Expires = DateTime.UtcNow.Add(CookieDuration); + } + + return cookie; + } + + private void PurgeExpiredAndEmptySessions() + { + string[] ids; + lock (_sessions) + { + ids = _sessions.Keys.ToArray(); + } + + foreach (var id in ids) + { + lock (_sessions) + { + if (!_sessions.TryGetValue(id, out var session)) + return; + + session.UnregisterIfNeeded(() => _sessions.TryRemove(id, out _)); + } + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/Session.cs b/src/EmbedIO/Sessions/Session.cs new file mode 100644 index 000000000..bc8d99071 --- /dev/null +++ b/src/EmbedIO/Sessions/Session.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace EmbedIO.Sessions +{ + /// + /// Provides useful constants related to session management. + /// + public static class Session + { + /// + /// The used to disambiguate session IDs. + /// Corresponds to . + /// + public const StringComparison IdComparison = StringComparison.Ordinal; + + /// + /// The used to disambiguate session keys. + /// Corresponds to . + /// + public const StringComparison KeyComparison = StringComparison.InvariantCulture; + + /// + /// The equality comparer used for session IDs. + /// Corresponds to . + /// + public static readonly IEqualityComparer IdComparer = StringComparer.Ordinal; + + /// + /// The equality comparer used for session keys. + /// Corresponds to . + /// + public static readonly IEqualityComparer KeyComparer = StringComparer.InvariantCulture; + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/SessionExtensions.cs b/src/EmbedIO/Sessions/SessionExtensions.cs new file mode 100644 index 000000000..326c4ff8e --- /dev/null +++ b/src/EmbedIO/Sessions/SessionExtensions.cs @@ -0,0 +1,46 @@ +using System; + +namespace EmbedIO.Sessions +{ + /// + /// Provides extension methods for types implementing . + /// + public static class SessionExtensions + { + /// Gets the value associated with the specified key. + /// The desired type of the value. + /// The on which this method is called. + /// The key whose value to get from the session. + /// + /// When this method returns, the value associated with the specified key, + /// if the key is found and the associated value is of type ; + /// otherwise, the default value for . + /// This parameter is passed uninitialized. + /// + /// if the key is found and the associated value is of type ; + /// otherwise, . + /// is . + /// is . + public static bool TryGetValue(this ISession @this, string key, out T value) + { + if (@this.TryGetValue(key, out var foundValue) && foundValue is T typedValue) + { + value = typedValue; + return true; + } + + value = default; + return false; + } + + /// Gets the value associated with the specified key. + /// The desired type of the value. + /// The on which this method is called. + /// The key whose value to get from the session. + /// The value associated with the specified key, + /// if the key is found and the associated value is of type ; + /// otherwise, the default value for . + public static T GetValue(this ISession @this, string key) + => @this.TryGetValue(key, out var value) && value is T typedValue ? typedValue : default; + } +} \ No newline at end of file diff --git a/src/EmbedIO/Sessions/SessionProxy.cs b/src/EmbedIO/Sessions/SessionProxy.cs new file mode 100644 index 000000000..dcbe3f6e6 --- /dev/null +++ b/src/EmbedIO/Sessions/SessionProxy.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; + +namespace EmbedIO.Sessions +{ + /// + /// Provides the same interface as a session object, + /// plus a basic interface to a session manager. + /// + /// + /// A session proxy can be used just as if it were a session object. + /// A session is automatically created wherever its data are accessed. + /// + /// + public sealed class SessionProxy : ISessionProxy + { + private readonly IHttpContext _context; + private readonly ISessionManager _sessionManager; + + private ISession _session; + private bool _onCloseRegistered; + + internal SessionProxy(IHttpContext context, ISessionManager sessionManager) + { + _context = context; + _sessionManager = sessionManager; + } + + /// + public bool Exists => _session != null; + + /// + public string Id + { + get + { + EnsureSessionExists(); + return _session.Id; + } + } + + /// + public TimeSpan Duration + { + get + { + EnsureSessionExists(); + return _session.Duration; + } + } + + /// + public DateTime LastActivity + { + get + { + EnsureSessionExists(); + return _session.LastActivity; + } + } + + /// + public int Count => _session?.Count ?? 0; + + /// + public bool IsEmpty => _session?.IsEmpty ?? true; + + /// + public object this[string key] + { + get + { + EnsureSessionExists(); + return _session[key]; + } + set + { + EnsureSessionExists(); + _session[key] = value; + } + } + + /// + public void Delete() + { + if (_session == null) + return; + + _sessionManager.Delete(_context, _session.Id); + _session = null; + } + + /// + public void Regenerate() + { + if (_session != null) + { + _sessionManager.Delete(_context, _session.Id); + } + + EnsureSessionManagerExists(); + _session = _sessionManager.Create(_context); + } + + /// + public void Clear() => _session?.Clear(); + + /// + public bool ContainsKey(string key) + { + EnsureSessionExists(); + return _session.ContainsKey(key); + } + + /// + public bool TryGetValue(string key, out object value) + { + EnsureSessionExists(); + return _session.TryGetValue(key, out value); + } + + /// + public bool TryRemove(string key, out object value) + { + EnsureSessionExists(); + return _session.TryRemove(key, out value); + } + + /// + public IReadOnlyList> TakeSnapshot() + { + EnsureSessionExists(); + return _session.TakeSnapshot(); + } + + private void EnsureSessionManagerExists() + { + if (_sessionManager == null) + throw new InvalidOperationException("No session manager registered in the web server."); + } + + private void EnsureSessionExists() + { + if (_session != null) + return; + + EnsureSessionManagerExists(); + _session = _sessionManager.Create(_context); + + if (_onCloseRegistered) + return; + + _context.OnClose(_sessionManager.OnContextClose); + _onCloseRegistered = true; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/HttpDate.cs b/src/EmbedIO/Utilities/HttpDate.cs new file mode 100644 index 000000000..529e4f0b7 --- /dev/null +++ b/src/EmbedIO/Utilities/HttpDate.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; + +namespace EmbedIO.Utilities +{ + /// + /// Provides standard methods to parse and format s according to various RFCs. + /// + public static class HttpDate + { + // https://github.com/dotnet/corefx/blob/master/src/Common/src/System/Net/HttpDateParser.cs + private static readonly string[] DateFormats = { + // "r", // RFC 1123, required output format but too strict for input + "ddd, d MMM yyyy H:m:s 'GMT'", // RFC 1123 (r, except it allows both 1 and 01 for date and time) + "ddd, d MMM yyyy H:m:s 'UTC'", // RFC 1123, UTC + "ddd, d MMM yyyy H:m:s", // RFC 1123, no zone - assume GMT + "d MMM yyyy H:m:s 'GMT'", // RFC 1123, no day-of-week + "d MMM yyyy H:m:s 'UTC'", // RFC 1123, UTC, no day-of-week + "d MMM yyyy H:m:s", // RFC 1123, no day-of-week, no zone + "ddd, d MMM yy H:m:s 'GMT'", // RFC 1123, short year + "ddd, d MMM yy H:m:s 'UTC'", // RFC 1123, UTC, short year + "ddd, d MMM yy H:m:s", // RFC 1123, short year, no zone + "d MMM yy H:m:s 'GMT'", // RFC 1123, no day-of-week, short year + "d MMM yy H:m:s 'UTC'", // RFC 1123, UTC, no day-of-week, short year + "d MMM yy H:m:s", // RFC 1123, no day-of-week, short year, no zone + + "dddd, d'-'MMM'-'yy H:m:s 'GMT'", // RFC 850 + "dddd, d'-'MMM'-'yy H:m:s 'UTC'", // RFC 850, UTC + "dddd, d'-'MMM'-'yy H:m:s zzz", // RFC 850, offset + "dddd, d'-'MMM'-'yy H:m:s", // RFC 850 no zone + "ddd MMM d H:m:s yyyy", // ANSI C's asctime() format + + "ddd, d MMM yyyy H:m:s zzz", // RFC 5322 + "ddd, d MMM yyyy H:m:s", // RFC 5322 no zone + "d MMM yyyy H:m:s zzz", // RFC 5322 no day-of-week + "d MMM yyyy H:m:s", // RFC 5322 no day-of-week, no zone + }; + + /// + /// Attempts to parse a string containing a date and time, and possibly a time zone offset, + /// in one of the formats specified in RFC850, + /// RFC1123, + /// and RFC5322, + /// or ANSI C's asctime() format. + /// + /// The string to parse. + /// When this method returns , + /// a representing the parsed date, time, and time zone offset. + /// This parameter is passed uninitialized. + /// if was successfully parsed; + /// otherwise, . + public static bool TryParse(string str, out DateTimeOffset result) => + DateTimeOffset.TryParseExact( + str, + DateFormats, + DateTimeFormatInfo.InvariantInfo, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal, + out result); + + /// + /// Formats the specified + /// according to RFC1123. + /// + /// The to format. + /// A string containing the formatted . + public static string Format(DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToUniversalTime().ToString("r", DateTimeFormatInfo.InvariantInfo); + + /// + /// Formats the specified + /// according to RFC1123. + /// + /// The to format. + /// A string containing the formatted . + public static string Format(DateTime dateTime) + => dateTime.ToUniversalTime().ToString("r", DateTimeFormatInfo.InvariantInfo); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/MimeTypeProviderStack.cs b/src/EmbedIO/Utilities/MimeTypeProviderStack.cs new file mode 100644 index 000000000..2324e59d8 --- /dev/null +++ b/src/EmbedIO/Utilities/MimeTypeProviderStack.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EmbedIO.Utilities +{ + /// + /// Manages a stack of MIME type providers. + /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code. + /// + /// + public sealed class MimeTypeProviderStack : IMimeTypeProvider + { + private readonly Stack _providers = new Stack(); + + /// + /// Pushes the specified MIME type provider on the stack. + /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code. + /// + /// The interface to push on the stack. + /// is . + public void Push(IMimeTypeProvider provider) + => _providers.Push(Validate.NotNull(nameof(provider), provider)); + + /// + /// Removes the most recently added MIME type provider from the stack. + /// This API supports the EmbedIO infrastructure and is not intended to be used directly from your code. + /// + public void Pop() => _providers.Pop(); + + /// + public string GetMimeType(string extension) + { + var result = _providers.Select(p => p.GetMimeType(extension)) + .FirstOrDefault(m => m != null); + + if (result == null) + MimeType.Associations.TryGetValue(extension, out result); + + return result; + } + + /// + public bool TryDetermineCompression(string mimeType, out bool preferCompression) + { + foreach (var provider in _providers) + { + if (provider.TryDetermineCompression(mimeType, out preferCompression)) + return true; + } + + preferCompression = default; + return false; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/NameValueCollectionExtensions.cs b/src/EmbedIO/Utilities/NameValueCollectionExtensions.cs new file mode 100644 index 000000000..c309ac4b5 --- /dev/null +++ b/src/EmbedIO/Utilities/NameValueCollectionExtensions.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; + +namespace EmbedIO.Utilities +{ + /// + /// Provides extension methods for . + /// + public static class NameValueCollectionExtensions + { + /// + /// Converts a to a dictionary of objects. + /// Values in the returned dictionary will wither be strings, or arrays of strings, + /// depending on the presence of multiple values for the same key in the collection. + /// + /// The on which this method is called. + /// A associating the collection's keys + /// with their values. + /// is . + public static Dictionary ToDictionary(this NameValueCollection @this) + => @this.Keys.Cast().ToDictionary(key => key, key => { + var values = @this.GetValues(key); + if (values == null) + return null; + + switch (values.Length) + { + case 0: + return null; + case 1: + return (object)values[0]; + default: + return (object)values; + } + }); + + /// + /// Converts a to a dictionary of strings. + /// + /// The on which this method is called. + /// A associating the collection's keys + /// with their values (or comma-separated lists in case of multiple values). + /// is . + public static Dictionary ToStringDictionary(this NameValueCollection @this) + => @this.Keys.Cast().ToDictionary(key => key, @this.Get); + + /// + /// Converts a to a dictionary of arrays of strings. + /// + /// The on which this method is called. + /// A associating the collection's keys + /// with arrays of their values. + /// is . + public static Dictionary ToArrayDictionary(this NameValueCollection @this) + => @this.Keys.Cast().ToDictionary(key => key, @this.GetValues); + + /// + /// Determines whether a contains one or more values + /// for the specified . + /// + /// The on which this method is called. + /// The key to look for. + /// if at least one value for + /// is present in the collection; otherwise, . + /// + /// is . + public static bool ContainsKey(this NameValueCollection @this, string key) + => @this.Keys.Cast().Contains(key); + + /// + /// Determines whether a contains one or more values + /// for the specified , at least one of which is equal to the specified + /// . Value comparisons are carried out using the + /// comparison type. + /// + /// The on which this method is called. + /// The name to look for. + /// The value to look for. + /// if at least one of the values for + /// in the collection is equal to ; otherwise, . + /// + /// is . + /// White space is trimmed from the start and end of each value before comparison. + /// + public static bool Contains(this NameValueCollection @this, string name, string value) + => Contains(@this, name, value, StringComparison.OrdinalIgnoreCase); + + /// + /// Determines whether a contains one or more values + /// for the specified , at least one of which is equal to the specified + /// . Value comparisons are carried out using the specified + /// . + /// + /// The on which this method is called. + /// The name to look for. + /// The value to look for. + /// One of the enumeration values + /// that specifies how the strings will be compared. + /// if at least one of the values for + /// in the collection is equal to ; otherwise, . + /// + /// is . + /// White space is trimmed from the start and end of each value before comparison. + /// + public static bool Contains(this NameValueCollection @this, string name, string value, StringComparison comparisonType) + { + value = value?.Trim(); + return @this[name]?.SplitByComma() + .Any(val => string.Equals(val?.Trim(), value, comparisonType)) ?? false; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/QValueList.cs b/src/EmbedIO/Utilities/QValueList.cs new file mode 100644 index 000000000..c19aaebe8 --- /dev/null +++ b/src/EmbedIO/Utilities/QValueList.cs @@ -0,0 +1,279 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace EmbedIO.Utilities +{ + /// + /// Represents a list of names with associated quality values extracted from an HTTP header, + /// e.g. gzip; q=0.9, deflate. + /// See RFC7231, section 5.3. + /// This class ignores and discards extensions (accept-ext in RFC7231 terminology). + /// If a name has one or more parameters (e.g. text/html;level=1) it is not + /// further parsed: parameters will appear as part of the name. + /// + public sealed class QValueList + { + /// + /// A value signifying "anything will do" in request headers. + /// For example, a request header of + /// Accept-Encoding: *;q=0.8, gzip means "I prefer GZip compression; + /// if it is not available, any other compression (including no compression at all) + /// is OK for me". + /// + public const string Wildcard = "*"; + + // This will match a quality value between two semicolons + // or between a semicolon and the end of a string. + // Match groups will be: + // Groups[0] = The matching string + // Groups[1] = If group is successful, "0"; otherwise, the weight is 1.000 + // Groups[2] = If group is successful, the decimal digits after 0 + // The part of string before the match contains the value and parameters (if any). + // The part of string after the match contains the extensions (if any). + // If there is no match, the whole string is just value and parameters (if any). + private static readonly Regex QualityValueRegex = new Regex( + @";[ \t]*q=(?:(?:1(?:\.(?:0{1,3}))?)|(?:(0)(?:\.(\d{1,3}))?))[ \t]*(?:;|,|$)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + + /// + /// Initializes a new instance of the class + /// by parsing comma-separated request header values. + /// + /// If set to , a value of * + /// will be treated as signifying "anything". + /// A list of comma-separated header values. + /// + public QValueList(bool useWildcard, string headerValues) + { + UseWildcard = useWildcard; + QValues = Parse(headerValues); + } + + /// + /// Initializes a new instance of the class + /// by parsing comma-separated request header values. + /// + /// If set to , a value of * + /// will be treated as signifying "anything". + /// An enumeration of header values. + /// Note that each element of the enumeration may in turn be + /// a comma-separated list. + /// + public QValueList(bool useWildcard, IEnumerable headerValues) + { + UseWildcard = useWildcard; + QValues = Parse(headerValues); + } + + /// + /// Initializes a new instance of the class + /// by parsing comma-separated request header values. + /// + /// If set to , a value of * + /// will be treated as signifying "anything". + /// An array of header values. + /// Note that each element of the array may in turn be + /// a comma-separated list. + /// + public QValueList(bool useWildcard, params string[] headerValues) + : this(useWildcard, headerValues as IEnumerable) + { + } + + /// + /// Gets a dictionary associating values with their relative weight + /// (an integer ranging from 0 to 1000) and their position in the + /// list of header values from which this instance has been constructed. + /// + /// + /// This property does not usually need to be used directly; + /// use the , , + /// , and + /// methods instead. + /// + /// + /// + /// + /// + public IReadOnlyDictionary QValues { get; } + + /// + /// Gets a value indicating whether * is treated as a special value + /// with the meaning of "anything". + /// + public bool UseWildcard { get; } + + /// + /// Determines whether the specified value is a possible candidate. + /// + /// The value. + /// if is a candidate; + /// otherwise, . + public bool IsCandidate(string value) + => TryGetCandidateValue(Validate.NotNull(nameof(value), value), out var candidate) && candidate.Weight > 0; + + /// + /// Attempts to determine whether the weight of a possible candidate. + /// + /// The value whose weight is to be determined. + /// When this method returns , + /// the weight of the candidate. + /// if is a candidate; + /// otherwise, . + public bool TryGetWeight(string value, out int weight) + { + var result = TryGetCandidateValue(Validate.NotNull(nameof(value), value), out var candidate); + weight = candidate.Weight; + return result; + } + + /// + /// Finds the value preferred by the client among an enumeration of values. + /// + /// The values. + /// The value preferred by the client, or + /// if none of the provided is accepted. + public string FindPreferred(IEnumerable values) + => FindPreferredCore(values, out var result) >= 0 ? result : null; + + /// + /// Finds the index of the value preferred by the client in a list of values. + /// + /// The values. + /// The index of the value preferred by the client, or -1 + /// if none of the values in is accepted. + public int FindPreferredIndex(IEnumerable values) => FindPreferredCore(values, out _); + + /// + /// Finds the index of the value preferred by the client in an array of values. + /// + /// The values. + /// The index of the value preferred by the client, or -1 + /// if none of the values in is accepted. + public int FindPreferredIndex(params string[] values) => FindPreferredIndex(values as IReadOnlyList); + + private static IReadOnlyDictionary Parse(string headerValues) + { + var result = new Dictionary(); + ParseCore(headerValues, result); + return result; + } + + private static IReadOnlyDictionary Parse(IEnumerable headerValues) + { + var result = new Dictionary(); + + if (headerValues == null) return result; + + foreach (var headerValue in headerValues) + ParseCore(headerValue, result); + + return result; + } + + private static void ParseCore(string text, IDictionary dictionary) + { + if (string.IsNullOrEmpty(text)) + return; + + var length = text.Length; + var position = 0; + var ordinal = 0; + while (position < length) + { + var stop = text.IndexOf(',', position); + if (stop < 0) + stop = length; + + string name; + var weight = 1000; + var match = QualityValueRegex.Match(text, position, stop - position); + if (match.Success) + { + var groups = match.Groups; + var wholeMatch = groups[0]; + name = text.Substring(position, wholeMatch.Index - position).Trim(); + if (groups[1].Success) + { + weight = 0; + if (groups[2].Success) + { + var digits = groups[2].Value; + var n = 0; + while (n < digits.Length) + { + weight = (10 * weight) + (digits[n] - '0'); + n++; + } + + while (n < 3) + { + weight = 10 * weight; + n++; + } + } + } + } + else + { + name = text.Substring(position, stop - position).Trim(); + } + + if (!string.IsNullOrEmpty(name)) + dictionary[name] = (weight, ordinal); + + position = stop + 1; + ordinal++; + } + } + + private int FindPreferredCore(IEnumerable values, out string result) + { + values = Validate.NotNull(nameof(values), values); + + result = null; + var best = -1; + + // Set initial values such as a weight of 0 can never win over them + (int Weight, int Ordinal) bestValue = (0, int.MinValue); + var i = 0; + foreach (var value in values) + { + if (value == null) + continue; + + if (TryGetCandidateValue(value, out var candidateValue) && CompareQualities(candidateValue, bestValue) > 0) + { + result = value; + best = i; + bestValue = candidateValue; + } + + i++; + } + + return best; + } + + private bool TryGetCandidateValue(string value, out (int Weight, int Ordinal) candidate) + => QValues.TryGetValue(value, out candidate) + || (UseWildcard && QValues.TryGetValue(Wildcard, out candidate)); + + private static int CompareQualities((int Weight, int Ordinal) a, (int Weight, int Ordinal) b) + { + if (a.Weight > b.Weight) + return 1; + + if (a.Weight < b.Weight) + return -1; + + if (a.Ordinal < b.Ordinal) + return 1; + + if (a.Ordinal > b.Ordinal) + return -1; + + return 0; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/QValueListExtensions.cs b/src/EmbedIO/Utilities/QValueListExtensions.cs new file mode 100644 index 000000000..4b989428b --- /dev/null +++ b/src/EmbedIO/Utilities/QValueListExtensions.cs @@ -0,0 +1,72 @@ +namespace EmbedIO.Utilities +{ + /// + /// Provides extension methods for . + /// + public static class QValueListExtensions + { + /// + /// Attempts to proactively negotiate a compression method for a response, + /// based on the contents of a . + /// + /// 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, the name of the compression method, + /// if content negotiation is successful. This parameter is passed uninitialized. + /// if content negotiation is successful; + /// otherwise, . + /// + /// If is empty, this method always returns , + /// setting to + /// and to . + /// + public static bool TryNegotiateContentEncoding( + this QValueList @this, + bool preferCompression, + out CompressionMethod compressionMethod, + out string compressionMethodName) + { + if (@this.QValues.Count < 1) + { + compressionMethod = CompressionMethod.None; + compressionMethodName = CompressionMethodNames.None; + return true; + } + + // https://tools.ietf.org/html/rfc7231#section-5.3.4 + // RFC7231, Section 5.3.4, rule #2: + // If the representation has no content-coding, then it is + // acceptable by default unless specifically excluded by the + // Accept - Encoding field stating either "identity;q=0" or "*;q=0" + // without a more specific entry for "identity". + if (!preferCompression && (!@this.TryGetWeight(CompressionMethodNames.None, out var weight) || weight > 0)) + { + compressionMethod = CompressionMethod.None; + compressionMethodName = CompressionMethodNames.None; + return true; + } + + var acceptableMethods = preferCompression + ? new[] { CompressionMethod.Gzip, CompressionMethod.Deflate, CompressionMethod.None } + : new[] { CompressionMethod.None, CompressionMethod.Gzip, CompressionMethod.Deflate }; + var acceptableMethodNames = preferCompression + ? new[] { CompressionMethodNames.Gzip, CompressionMethodNames.Deflate, CompressionMethodNames.None } + : new[] { CompressionMethodNames.None, CompressionMethodNames.Gzip, CompressionMethodNames.Deflate }; + + var acceptableMethodIndex = @this.FindPreferredIndex(acceptableMethodNames); + if (acceptableMethodIndex < 0) + { + compressionMethod = default; + compressionMethodName = default; + return false; + } + + compressionMethod = acceptableMethods[acceptableMethodIndex]; + compressionMethodName = acceptableMethodNames[acceptableMethodIndex]; + return true; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/StringExtensions.cs b/src/EmbedIO/Utilities/StringExtensions.cs new file mode 100644 index 000000000..a459b66fb --- /dev/null +++ b/src/EmbedIO/Utilities/StringExtensions.cs @@ -0,0 +1,45 @@ +using System; + +namespace EmbedIO.Utilities +{ + /// + /// Provides extension methods for . + /// + public static class StringExtensions + { + private static readonly char[] CommaSplitChars = {','}; + + /// Splits a string into substrings based on the specified . + /// The returned array includes empty array elements if two or more consecutive delimiters are found + /// in . + /// The on which this method is called. + /// An array of s to use as delimiters. + /// An array whose elements contain the substrings in that are delimited + /// by one or more characters in . + /// is . + public static string[] SplitByAny(this string @this, params char[] delimiters) => @this.Split(delimiters); + + /// Splits a string into substrings, using the comma (,) character as a delimiter. + /// The returned array includes empty array elements if two or more commas are found in . + /// The on which this method is called. + /// An array whose elements contain the substrings in that are delimited by commas. + /// is . + /// + public static string[] SplitByComma(this string @this) => @this.Split(CommaSplitChars); + + /// Splits a string into substrings, using the comma (,) character as a delimiter. + /// You can specify whether the substrings include empty array elements. + /// The on which this method is called. + /// to omit empty array elements from the array returned; + /// or to include empty array elements in the array returned. + /// + /// An array whose elements contain the substrings in that are delimited by commas. + /// For more information, see the Remarks section of the method. + /// + /// is . + /// options is not one of the values. + /// + public static string[] SplitByComma(this string @this, StringSplitOptions options) => + @this.Split(CommaSplitChars, options); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/UniqueIdGenerator.cs b/src/EmbedIO/Utilities/UniqueIdGenerator.cs new file mode 100644 index 000000000..66b5ba952 --- /dev/null +++ b/src/EmbedIO/Utilities/UniqueIdGenerator.cs @@ -0,0 +1,16 @@ +using System; + +namespace EmbedIO.Utilities +{ + /// + /// Generates locally unique string IDs, mainly for logging purposes. + /// + public static class UniqueIdGenerator + { + /// + /// Generates and returns a unique ID. + /// + /// The generated ID. + public static string GetNext() => Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Substring(0, 22); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/UrlEncodedDataParser.cs b/src/EmbedIO/Utilities/UrlEncodedDataParser.cs new file mode 100644 index 000000000..b6c962662 --- /dev/null +++ b/src/EmbedIO/Utilities/UrlEncodedDataParser.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Specialized; +using System.Net; +using EmbedIO.Internal; + +namespace EmbedIO.Utilities +{ + /// + /// Parses URL queries or URL-encoded HTML forms. + /// + public static class UrlEncodedDataParser + { + /// + /// Parses a URL query or URL-encoded HTML form. + /// Unlike , the returned + /// will have bracketed indexes stripped away; + /// for example, a[0]=1&a[1]=2 will yield the same result as a=1&a=2, + /// i.e. a with one key (a) associated with + /// two values (1 and 2). + /// + /// The string to parse. + /// If this parameter is , + /// tokens not followed by an equal sign (e.g. this in a=1&this&b=2) + /// will be grouped as values of a null key. + /// This is the same behavior as the and + /// properties. + /// If this parameter is , tokens not followed by an equal sign + /// (e.g. this in a=1&this&b=2) will be considered keys with an empty + /// value. This is the same behavior as the + /// extension method. + /// (the default) to return + /// a mutable (non-read-only) collection; to return a read-only collection. + /// A containing the parsed data. + public static NameValueCollection Parse(string source, bool groupFlags, bool mutableResult = true) + { + var result = new LockableNameValueCollection(); + + // Verify there is data to parse; otherwise, return an empty collection. + if (string.IsNullOrEmpty(source)) + { + if (!mutableResult) + result.MakeReadOnly(); + + return result; + } + + void AddKeyValuePair(string key, string value) + { + if (key != null) + { + // Decode the key. + key = WebUtility.UrlDecode(key); + + // Discard bracketed index (used e.g. by PHP) + var bracketPos = key.IndexOf("[", StringComparison.Ordinal); + if (bracketPos > 0) + key = key.Substring(0, bracketPos); + } + + // Decode the value. + value = WebUtility.UrlDecode(value); + + // Add the KVP to the collection. + result.Add(key, value); + } + + // Skip the initial question mark, + // in case source is the Query property of a Uri. + var kvpPos = source[0] == '?' ? 1 : 0; + var length = source.Length; + while (kvpPos < length) + { + var separatorPos = kvpPos; + var equalPos = -1; + + while (separatorPos < length) + { + var c = source[separatorPos]; + if (c == '&') + break; + + if (c == '=' && equalPos < 0) + equalPos = separatorPos; + + separatorPos++; + } + + // Split by the equals char into key and value. + // Some KVPS will have only their key, some will have both key and value + // Some other might be repeated which really means an array + if (equalPos < 0) + { + if (groupFlags) + { + AddKeyValuePair(null, source.Substring(kvpPos, separatorPos - kvpPos)); + } + else + { + AddKeyValuePair(source.Substring(kvpPos, separatorPos - kvpPos), string.Empty); + } + } + else + { + AddKeyValuePair( + source.Substring(kvpPos, equalPos - kvpPos), + source.Substring(equalPos + 1, separatorPos - equalPos - 1)); + } + + // Edge case: if the last character in source is '&', + // there's an empty KVP that we would otherwise skip. + if (separatorPos == length - 1) + { + AddKeyValuePair(groupFlags ? null : string.Empty, string.Empty); + break; + } + + // On to next KVP + kvpPos = separatorPos + 1; + } + + if (!mutableResult) + result.MakeReadOnly(); + + return result; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/UrlPath.cs b/src/EmbedIO/Utilities/UrlPath.cs new file mode 100644 index 000000000..7dbcf583f --- /dev/null +++ b/src/EmbedIO/Utilities/UrlPath.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace EmbedIO.Utilities +{ + /// + /// Provides utility methods to work with URL paths. + /// + public static class UrlPath + { + /// + /// The root URL path value, i.e. "/". + /// + public const string Root = "/"; + + private static readonly Regex MultipleSlashRegex = new Regex("//+", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Determines whether a string is a valid URL path. + /// + /// The URL path. + /// + /// if the specified URL path is valid; otherwise, . + /// + /// + /// For a string to be a valid URL path, it must not be , + /// must not be empty, and must start with a slash (/) character. + /// To ensure that a method parameter is a valid URL path, use . + /// + /// + /// + /// + public static bool IsValid(string urlPath) => ValidateInternal(nameof(urlPath), urlPath) == null; + + /// + /// Normalizes the specified URL path. + /// + /// The URL path. + /// if set to , treat the URL path + /// as a base path, i.e. ensure it ends with a slash (/) character; + /// otherwise, ensure that it does NOT end with a slash character. + /// The normalized path. + /// + /// is not a valid URL path. + /// + /// + /// A normalized URL path is one where each run of two or more slash + /// (/) characters has been replaced with a single slash character. + /// This method does NOT try to decode URL-encoded characters. + /// If you are sure that is a valid URL path, + /// for example because you have called and it returned + /// , then you may call + /// instead of this method. is slightly faster because + /// it skips the initial validity check. + /// There is no need to call this method for a method parameter + /// for which you have already called . + /// + /// + /// + /// + public static string Normalize(string urlPath, bool isBasePath) + { + var exception = ValidateInternal(nameof(urlPath), urlPath); + if (exception != null) + throw exception; + + return UnsafeNormalize(urlPath, isBasePath); + } + + /// + /// Normalizes the specified URL path, assuming that it is valid. + /// + /// The URL path. + /// if set to , treat the URL path + /// as a base path, i.e. ensure it ends with a slash (/) character; + /// otherwise, ensure that it does NOT end with a slash character. + /// The normalized path. + /// + /// A normalized URL path is one where each run of two or more slash + /// (/) characters has been replaced with a single slash character. + /// This method does NOT try to decode URL-encoded characters. + /// If is not valid, the behavior of + /// this method is unspecified. You should call this method only after + /// has returned + /// for the same . + /// You should call instead of this method + /// if you are not sure that is valid. + /// There is no need to call this method for a method parameter + /// for which you have already called . + /// + /// + /// + /// + public static string UnsafeNormalize(string urlPath, bool isBasePath) + { + // Replace each run of multiple slashes with a single slash + urlPath = MultipleSlashRegex.Replace(urlPath, "/"); + + // The root path needs no further checking. + var length = urlPath.Length; + if (length == 1) + return urlPath; + + // Base URL paths must end with a slash; + // non-base URL paths must NOT end with a slash. + // The final slash is irrelevant for the URL itself + // (it has to map the same way with or without it) + // but makes comparing and mapping URLs a lot simpler. + var finalPosition = length - 1; + var endsWithSlash = urlPath[finalPosition] == '/'; + return isBasePath + ? (endsWithSlash ? urlPath : urlPath + "/") + : (endsWithSlash ? urlPath.Substring(0, finalPosition) : urlPath); + } + + /// + /// Determines whether the specified URL path is prefixed by the specified base URL path. + /// + /// The URL path. + /// The base URL path. + /// + /// if is prefixed by ; + /// otherwise, . + /// + /// + /// is not a valid URL path. + /// - or - + /// is not a valid base URL path. + /// + /// + /// This method returns even if the two URL paths are equivalent, + /// for example if both are "/", or if is "/download" and + /// is "/download/". + /// If you are sure that both and + /// are valid and normalized, for example because you have called , + /// then you may call instead of this method. + /// is slightly faster because it skips validity checks. + /// + /// + /// + /// + /// + public static bool HasPrefix(string urlPath, string baseUrlPath) + => UnsafeHasPrefix( + Validate.UrlPath(nameof(urlPath), urlPath, false), + Validate.UrlPath(nameof(baseUrlPath), baseUrlPath, true)); + + /// + /// Determines whether the specified URL path is prefixed by the specified base URL path, + /// assuming both paths are valid and normalized. + /// + /// The URL path. + /// The base URL path. + /// + /// if is prefixed by ; + /// otherwise, . + /// + /// + /// Unless both and are valid, + /// normalized URL paths, the behavior of this method is unspecified. You should call this method + /// only after calling either or + /// to check and normalize both parameters. + /// If you are not sure about the validity and/or normalization of parameters, + /// call instead of this method. + /// This method returns even if the two URL paths are equivalent, + /// for example if both are "/", or if is "/download" and + /// is "/download/". + /// + /// + /// + /// + /// + public static bool UnsafeHasPrefix(string urlPath, string baseUrlPath) + => urlPath.StartsWith(baseUrlPath, StringComparison.Ordinal) + || (urlPath.Length == baseUrlPath.Length - 1 && baseUrlPath.StartsWith(urlPath, StringComparison.Ordinal)); + + /// + /// Strips a base URL path fom a URL path, obtaining a relative path. + /// + /// The URL path. + /// The base URL path. + /// The relative path, or if + /// is not prefixed by . + /// + /// is not a valid URL path. + /// - or - + /// is not a valid base URL path. + /// + /// + /// The returned relative path is NOT prefixed by a slash (/) character. + /// If and are equivalent, + /// for example if both are "/", or if is "/download" + /// and is "/download/", this method returns an empty string. + /// If you are sure that both and + /// are valid and normalized, for example because you have called , + /// then you may call instead of this method. + /// is slightly faster because it skips validity checks. + /// + /// + /// + /// + /// + public static string StripPrefix(string urlPath, string baseUrlPath) + => UnsafeStripPrefix( + Validate.UrlPath(nameof(urlPath), urlPath, false), + Validate.UrlPath(nameof(baseUrlPath), baseUrlPath, true)); + + /// + /// Strips a base URL path fom a URL path, obtaining a relative path, + /// assuming both paths are valid and normalized. + /// + /// The URL path. + /// The base URL path. + /// The relative path, or if + /// is not prefixed by . + /// + /// Unless both and are valid, + /// normalized URL paths, the behavior of this method is unspecified. You should call this method + /// only after calling either or + /// to check and normalize both parameters. + /// If you are not sure about the validity and/or normalization of parameters, + /// call instead of this method. + /// The returned relative path is NOT prefixed by a slash (/) character. + /// If and are equivalent, + /// for example if both are "/", or if is "/download" + /// and is "/download/", this method returns an empty string. + /// + /// + /// + /// + /// + public static string UnsafeStripPrefix(string urlPath, string baseUrlPath) + { + if (!UnsafeHasPrefix(urlPath, baseUrlPath)) + return null; + + // The only case where UnsafeHasPrefix returns true for a urlPath shorter than baseUrlPath + // is urlPath == (baseUrlPath minus the final slash). + return urlPath.Length < baseUrlPath.Length + ? string.Empty + : urlPath.Substring(baseUrlPath.Length); + } + + /// + /// Splits the specified URL path into segments. + /// + /// The URL path. + /// An enumeration of path segments. + /// is not a valid URL path. + /// + /// A root URL path (/) will result in an empty enumeration. + /// The returned enumeration will be the same whether is a base URL path or not. + /// If you are sure that is valid and normalized, + /// for example because you have called , + /// then you may call instead of this method. + /// is slightly faster because it skips validity checks. + /// + /// + /// + /// + public static IEnumerable Split(string urlPath) + => UnsafeSplit(Validate.UrlPath(nameof(urlPath), urlPath, false)); + + /// + /// Splits the specified URL path into segments, assuming it is valid and normalized. + /// + /// The URL path. + /// An enumeration of path segments. + /// + /// Unless is a valid, normalized URL path, + /// the behavior of this method is unspecified. You should call this method + /// only after calling either or + /// to check and normalize both parameters. + /// If you are not sure about the validity and/or normalization of , + /// call instead of this method. + /// A root URL path (/) will result in an empty enumeration. + /// The returned enumeration will be the same whether is a base URL path or not. + /// + /// + /// + /// + public static IEnumerable UnsafeSplit(string urlPath) + { + var length = urlPath.Length; + var position = 1; // Skip initial slash + while (position < length) + { + var slashPosition = urlPath.IndexOf('/', position); + if (slashPosition < 0) + { + yield return urlPath.Substring(position); + break; + } + + yield return urlPath.Substring(position, slashPosition - position); + position = slashPosition + 1; + } + } + + internal static Exception ValidateInternal(string argumentName, string value) + { + if (value == null) + return new ArgumentNullException(argumentName); + + if (value.Length == 0) + return new ArgumentException("URL path is empty.", argumentName); + + if (value[0] != '/') + return new ArgumentException("URL path does not start with a slash.", argumentName); + + return null; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/Validate-MimeType.cs b/src/EmbedIO/Utilities/Validate-MimeType.cs new file mode 100644 index 000000000..a13b2dfa2 --- /dev/null +++ b/src/EmbedIO/Utilities/Validate-MimeType.cs @@ -0,0 +1,35 @@ +using System; + +namespace EmbedIO.Utilities +{ + /// + /// Provides validation methods for method arguments. + /// + public static partial class Validate + { + /// + /// Ensures that a argument is valid as MIME type or media range as defined by + /// RFC7231, Section 5,3.2. + /// + /// The name of the argument to validate. + /// The value to validate. + /// If , media ranges (i.e. strings of the form */* + /// and type/*) are considered valid; otherwise, they are rejected as invalid. + /// , if it is a valid MIME type or media range. + /// is . + /// + /// is the empty string. + /// - or - + /// is not a valid MIME type or media range. + /// + public static string MimeType(string argumentName, string value, bool acceptMediaRange) + { + value = NotNullOrEmpty(argumentName, value); + + if (!EmbedIO.MimeType.IsMimeType(value, acceptMediaRange)) + throw new ArgumentException("MIME type is not valid.", argumentName); + + return value; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/Validate-Paths.cs b/src/EmbedIO/Utilities/Validate-Paths.cs new file mode 100644 index 000000000..a87547497 --- /dev/null +++ b/src/EmbedIO/Utilities/Validate-Paths.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using System.Security; + +namespace EmbedIO.Utilities +{ + partial class Validate + { + private static readonly char[] InvalidLocalPathChars = GetInvalidLocalPathChars(); + + /// + /// Ensures that the value of an argument is a valid URL path + /// and normalizes it. + /// + /// The name of the argument to validate. + /// The value to validate. + /// If set to true, the returned path + /// is ensured to end in a slash (/) character; otherwise, the returned path is + /// ensured to not end in a slash character unless it is "/". + /// The normalized URL path. + /// is . + /// + /// is empty. + /// - or - + /// does not start with a slash (/) character. + /// + /// + public static string UrlPath(string argumentName, string value, bool isBasePath) + { + var exception = Utilities.UrlPath.ValidateInternal(argumentName, value); + if (exception != null) + throw exception; + + return Utilities.UrlPath.Normalize(value, isBasePath); + } + + /// + /// Ensures that the value of an argument is a valid local path + /// and, optionally, gets the corresponding full path. + /// + /// The name of the argument to validate. + /// The value to validate. + /// to get the full path, to leave the path as is.. + /// The local path, or the full path if is . + /// is . + /// + /// is empty. + /// - or - + /// contains only white space. + /// - or - + /// contains one or more invalid characters. + /// - or - + /// is and the full path could not be obtained. + /// + public static string LocalPath(string argumentName, string value, bool getFullPath) + { + if (value == null) + throw new ArgumentNullException(argumentName); + + if (value.Length == 0) + throw new ArgumentException("Local path is empty.", argumentName); + + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Local path contains only white space.", argumentName); + + if (value.IndexOfAny(InvalidLocalPathChars) >= 0) + throw new ArgumentException("Local path contains one or more invalid characters.", argumentName); + + if (getFullPath) + { + try + { + value = Path.GetFullPath(value); + } + catch (Exception e) when (e is ArgumentException || e is SecurityException || e is NotSupportedException || e is PathTooLongException) + { + throw new ArgumentException("Could not get the full local path.", argumentName, e); + } + } + + return value; + } + + private static char[] GetInvalidLocalPathChars() + { + var systemChars = Path.GetInvalidPathChars(); + var p = systemChars.Length; + var result = new char[p + 2]; + Array.Copy(systemChars, result, p); + result[p++] = '*'; + result[p] = '?'; + return result; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/Validate-Rfc2616.cs b/src/EmbedIO/Utilities/Validate-Rfc2616.cs new file mode 100644 index 000000000..7927dff81 --- /dev/null +++ b/src/EmbedIO/Utilities/Validate-Rfc2616.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; + +namespace EmbedIO.Utilities +{ + /// + /// Provides validation methods for method arguments. + /// + public static partial class Validate + { + private static readonly char[] ValidRfc2616TokenChars = GetValidRfc2616TokenChars(); + + /// + /// Ensures that a argument is valid as a token as defined by + /// RFC2616, Section 2.2. + /// RFC2616 tokens are used, for example, as: + /// + /// cookie names, as stated in RFC6265, Section 4.1.1; + /// WebSocket protocol names, as stated in RFC6455, Section 4.3. + /// + /// Only a restricted set of characters are allowed in tokens, including: + /// + /// upper- and lower-case letters of the English alphabet; + /// decimal digits; + /// the following non-alphanumeric characters: + /// !, #, $, %, &, ', *, +, + /// -, ., ^, _, `, |, ~. + /// + /// + /// The name of the argument to validate. + /// The value to validate. + /// , if it is a valid token. + /// is . + /// + /// is the empty string. + /// - or - + /// contains one or more characters that are not allowed in a token. + /// + public static string Rfc2616Token(string argumentName, string value) + { + value = NotNullOrEmpty(argumentName, value); + + if (!IsRfc2616Token(value)) + throw new ArgumentException("Token contains one or more invalid characters.", argumentName); + + return value; + } + + internal static bool IsRfc2616Token(string value) + => !string.IsNullOrEmpty(value) + && !value.Any(c => c < '\x21' || c > '\x7E' || Array.BinarySearch(ValidRfc2616TokenChars, c) < 0); + + private static char[] GetValidRfc2616TokenChars() + => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&'*+-.^_`|~" + .OrderBy(c => c) + .ToArray(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/Validate-Route.cs b/src/EmbedIO/Utilities/Validate-Route.cs new file mode 100644 index 000000000..e66453127 --- /dev/null +++ b/src/EmbedIO/Utilities/Validate-Route.cs @@ -0,0 +1,32 @@ +using System; + +namespace EmbedIO.Utilities +{ + partial class Validate + { + /// + /// Ensures that the value of an argument is a valid route. + /// + /// The name of the argument to validate. + /// The value to validate. + /// if the argument must be a base route; + /// if the argument must be a non-base route. + /// , if it is a valid route. + /// is . + /// + /// is empty. + /// - or - + /// does not start with a slash (/) character. + /// - or - + /// does not comply with route syntax. + /// + public static string Route(string argumentName, string value, bool isBaseRoute) + { + var exception = Routing.Route.ValidateInternal(argumentName, value, isBaseRoute); + if (exception != null) + throw exception; + + return Utilities.UrlPath.UnsafeNormalize(value, isBaseRoute); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/Utilities/Validate.cs b/src/EmbedIO/Utilities/Validate.cs new file mode 100644 index 000000000..01d0f1403 --- /dev/null +++ b/src/EmbedIO/Utilities/Validate.cs @@ -0,0 +1,125 @@ +using System; + +namespace EmbedIO.Utilities +{ + /// + /// Provides validation methods for method arguments. + /// + public static partial class Validate + { + /// + /// Ensures that an argument is not . + /// + /// The type of the argument to validate. + /// The name of the argument to validate. + /// The value to validate. + /// if not . + /// is . + public static T NotNull(string argumentName, T value) + where T : class + => value ?? throw new ArgumentNullException(argumentName); + + /// + /// Ensures that a argument is neither nor the empty string. + /// + /// The name of the argument to validate. + /// The value to validate. + /// if neither nor the empty string. + /// is . + /// is the empty string. + public static string NotNullOrEmpty(string argumentName, string value) + { + if (value == null) + throw new ArgumentNullException(argumentName); + + if (value.Length == 0) + throw new ArgumentException("String is empty.", argumentName); + + return value; + } + + /// + /// Ensures that a valid URL can be constructed from a argument. + /// + /// Name of the argument. + /// The value. + /// Specifies whether is a relative URL, absolute URL, or is indeterminate. + /// Ensure that, if is an absolute URL, its scheme is either http or https. + /// The string representation of the constructed URL. + /// is . + /// + /// is not a valid URL. + /// - or - + /// is , is an absolute URL, + /// and 's scheme is neither http nor https. + /// + /// + public static string Url( + string argumentName, + string value, + UriKind uriKind = UriKind.RelativeOrAbsolute, + bool enforceHttp = false) + { + Uri uri; + try + { + uri = new Uri(NotNull(argumentName, value), uriKind); + } + catch (UriFormatException e) + { + throw new ArgumentException("URL is not valid.", argumentName, e); + } + + if (enforceHttp && uri.IsAbsoluteUri && uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + throw new ArgumentException("URL scheme is neither HTTP nor HTTPS.", argumentName); + + return uri.ToString(); + } + + /// + /// Ensures that a valid URL, either absolute or relative to the given , + /// can be constructed from a argument and returns the absolute URL + /// obtained by combining and . + /// + /// Name of the argument. + /// The value. + /// The base URI for relative URLs. + /// Ensure that the resulting URL's scheme is either http or https. + /// The string representation of the constructed URL. + /// + /// is . + /// - or - + /// is . + /// + /// + /// is not an absolute URI. + /// - or - + /// is not a valid URL. + /// - or - + /// is , + /// and the combination of and has a scheme + /// that is neither http nor https. + /// + /// + public static string Url(string argumentName, string value, Uri baseUri, bool enforceHttp = false) + { + if (!NotNull(nameof(baseUri), baseUri).IsAbsoluteUri) + throw new ArgumentException("Base URI is not an absolute URI.", nameof(baseUri)); + + Uri uri; + try + { + uri = new Uri(baseUri, new Uri(NotNull(argumentName, value), UriKind.RelativeOrAbsolute)); + } + catch (UriFormatException e) + { + throw new ArgumentException("URL is not valid.", argumentName, e); + } + + if (enforceHttp && uri.IsAbsoluteUri && uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + throw new ArgumentException("URL scheme is neither HTTP nor HTTPS.", argumentName); + + return uri.ToString(); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/FormDataAttribute.cs b/src/EmbedIO/WebApi/FormDataAttribute.cs new file mode 100644 index 000000000..f662371c8 --- /dev/null +++ b/src/EmbedIO/WebApi/FormDataAttribute.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Specialized; +using System.Threading.Tasks; + +namespace EmbedIO.WebApi +{ + /// + /// Specified that a parameter of a controller method will receive a + /// of HTML form data, obtained by deserializing a request body with a content type + /// of application/x-www-form-urlencoded. + /// The received collection will be read-only. + /// This class cannot be inherited. + /// + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class FormDataAttribute : Attribute, IRequestDataAttribute + { + /// + public Task GetRequestDataAsync(WebApiController controller, string parameterName) + => controller.HttpContext.GetRequestFormDataAsync(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/FormFieldAttribute.cs b/src/EmbedIO/WebApi/FormFieldAttribute.cs new file mode 100644 index 000000000..4f49928b6 --- /dev/null +++ b/src/EmbedIO/WebApi/FormFieldAttribute.cs @@ -0,0 +1,172 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EmbedIO.Utilities; +using Swan; + +namespace EmbedIO.WebApi +{ + /// + /// Specifies that a parameter of a controller method will receive the value(s) of a field in a HTML form, + /// obtained by deserializing a request body with a content type of application/x-www-form-urlencoded. + /// The parameter carrying this attribute can be either a simple type or a one-dimension array. + /// If multiple values are present for the field, a non-array parameter will receive the last specified value, + /// while an array parameter will receive an array of field values converted to the element type of the + /// parameter. + /// If a single value is present for the field, a non-array parameter will receive the value converted + /// to the type of the parameter, while an array parameter will receive an array of length 1, containing + /// the value converted to the element type of the parameter + /// If no values are present for the field and the property is + /// , a 400 Bad Request response will be sent to the client, with a message + /// specifying the name of the missing field. + /// If no values are present for the field and the property is + /// , a non-array parameter will receive the default value for its type, while + /// an array parameter will receive an array of length 0. + /// This class cannot be inherited. + /// + /// + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class FormFieldAttribute : + Attribute, + IRequestDataAttribute, + IRequestDataAttribute, + IRequestDataAttribute + { + /// + /// Initializes a new instance of the class. + /// The name of the form field to extract will be equal to the name of the parameter + /// carrying this attribute. + /// + public FormFieldAttribute() + : this(false, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the form field to extract. + /// is . + /// is the empty string (""). + public FormFieldAttribute(string fieldName) + : this(false, Validate.NotNullOrEmpty(nameof(fieldName), fieldName)) + { + } + + /// + /// Initializes a new instance of the class. + /// The name of the form field to extract will be equal to the name of the parameter + /// carrying this attribute. + /// + /// If set to , a 400 Bad Request + /// response will be sent to the client if no values are found for the field; if set to + /// , a default value will be assumed. + public FormFieldAttribute(bool badRequestIfMissing) + : this(badRequestIfMissing, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the form field to extract. + /// is . + /// is the empty string (""). + /// If set to , a 400 Bad Request + /// response will be sent to the client if no values are found for the field; if set to + /// , a default value will be assumed. + public FormFieldAttribute(string fieldName, bool badRequestIfMissing) + : this(badRequestIfMissing, Validate.NotNullOrEmpty(nameof(fieldName), fieldName)) + { + } + + private FormFieldAttribute(bool badRequestIfMissing, string fieldName) + { + BadRequestIfMissing = badRequestIfMissing; + FieldName = fieldName; + } + + /// + /// Gets the name of the form field that this attribute will extract, + /// or if the name of the parameter carrying this + /// attribute is to be used as field name. + /// + public string FieldName { get; } + + /// + /// Gets or sets a value indicating whether to send a 400 Bad Request response + /// to the client if the submitted form contains no values for the field. + /// If this property is and the submitted form + /// contains no values for the field, the 400 Bad Request response sent + /// to the client will contain a reference to the missing field. + /// If this property is and the submitted form + /// contains no values for the field, the default value for the parameter + /// (or a zero-length array if the parameter is of an array type) + /// will be passed to the controller method. + /// + public bool BadRequestIfMissing { get; } + + async Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + string parameterName) + { + var data = await controller.HttpContext.GetRequestFormDataAsync() + .ConfigureAwait(false); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing form field {fieldName}."); + + return data.GetValues(fieldName)?.LastOrDefault(); + } + + async Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + string parameterName) + { + var data = await controller.HttpContext.GetRequestFormDataAsync() + .ConfigureAwait(false); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing form field {fieldName}."); + + return data.GetValues(fieldName) ?? Array.Empty(); + } + + async Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + Type type, + string parameterName) + { + var data = await controller.HttpContext.GetRequestFormDataAsync() + .ConfigureAwait(false); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing form field {fieldName}."); + + if (type.IsArray) + { + var fieldValues = data.GetValues(fieldName) ?? Array.Empty(); + if (!FromString.TryConvertTo(type, fieldValues, out var result)) + throw HttpException.BadRequest($"Cannot convert field {fieldName} to an array of {type.GetElementType().Name}."); + + return result; + } + else + { + var fieldValue = data.GetValues(fieldName)?.LastOrDefault(); + if (fieldValue == null) + return type.IsValueType ? Activator.CreateInstance(type) : null; + + if (!FromString.TryConvertTo(type, fieldValue, out var result)) + throw HttpException.BadRequest($"Cannot convert field {fieldName} to {type.Name}."); + + return result; + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/IRequestDataAttribute`1.cs b/src/EmbedIO/WebApi/IRequestDataAttribute`1.cs new file mode 100644 index 000000000..9bc60e1fc --- /dev/null +++ b/src/EmbedIO/WebApi/IRequestDataAttribute`1.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading.Tasks; + +namespace EmbedIO.WebApi +{ + /// + /// Represents an attribute, applied to a parameter of a web API controller method, + /// that causes the parameter to be passed deserialized data from a request. + /// + /// The type of the controller. + /// + public interface IRequestDataAttribute + where TController : WebApiController + { + /// + /// Asynchronously obtains data from a controller's context. + /// + /// The controller. + /// The type of the parameter that has to receive the data. + /// The name of the parameter that has to receive the data. + /// a whose result will be the data + /// to pass as a parameter to a controller method. + Task GetRequestDataAsync(TController controller, Type type, string parameterName); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/IRequestDataAttribute`2.cs b/src/EmbedIO/WebApi/IRequestDataAttribute`2.cs new file mode 100644 index 000000000..c2775e39b --- /dev/null +++ b/src/EmbedIO/WebApi/IRequestDataAttribute`2.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace EmbedIO.WebApi +{ + /// + /// Represents an attribute, applied to a parameter of a web API controller method, + /// that causes the parameter to be passed deserialized data from a request. + /// + /// The type of the controller. + /// The type of the data. + /// + public interface IRequestDataAttribute + where TController : WebApiController + { + /// + /// Asynchronously obtains data from a controller's context. + /// + /// The controller. + /// The name of the parameter that has to receive the data. + /// a whose result will be the data + /// to pass as a parameter to a controller method. + Task GetRequestDataAsync(TController controller, string parameterName); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/QueryDataAttribute.cs b/src/EmbedIO/WebApi/QueryDataAttribute.cs new file mode 100644 index 000000000..675142e27 --- /dev/null +++ b/src/EmbedIO/WebApi/QueryDataAttribute.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Specialized; +using System.Threading.Tasks; + +namespace EmbedIO.WebApi +{ + /// + /// Specified that a parameter of a controller method will receive a + /// of HTML form data, obtained by deserializing a request URL query. + /// The received collection will be read-only. + /// This class cannot be inherited. + /// + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class QueryDataAttribute : Attribute, IRequestDataAttribute + { + /// + public Task GetRequestDataAsync(WebApiController controller, string parameterName) + => Task.FromResult(controller.HttpContext.GetRequestQueryData()); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/QueryFieldAttribute.cs b/src/EmbedIO/WebApi/QueryFieldAttribute.cs new file mode 100644 index 000000000..670866805 --- /dev/null +++ b/src/EmbedIO/WebApi/QueryFieldAttribute.cs @@ -0,0 +1,169 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EmbedIO.Utilities; +using Swan; + +namespace EmbedIO.WebApi +{ + /// + /// Specifies that a parameter of a controller method will receive the value of a field, + /// obtained by deserializing a request URL query. + /// The parameter carrying this attribute can be either a simple type or a one-dimension array. + /// If multiple values are present for the field, a non-array parameter will receive the last specified value, + /// while an array parameter will receive an array of field values converted to the element type of the + /// parameter. + /// If a single value is present for the field, a non-array parameter will receive the value converted + /// to the type of the parameter, while an array parameter will receive an array of length 1, containing + /// the value converted to the element type of the parameter + /// If no values are present for the field and the property is + /// , a 400 Bad Request response will be sent to the client, with a message + /// specifying the name of the missing field. + /// If no values are present for the field and the property is + /// , a non-array parameter will receive the default value for its type, while + /// an array parameter will receive an array of length 0. + /// This class cannot be inherited. + /// + /// + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class QueryFieldAttribute : + Attribute, + IRequestDataAttribute, + IRequestDataAttribute, + IRequestDataAttribute + { + /// + /// Initializes a new instance of the class. + /// The name of the query field to extract will be equal to the name of the parameter + /// carrying this attribute. + /// + public QueryFieldAttribute() + : this(false, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the query field to extract. + /// is . + /// is the empty string (""). + public QueryFieldAttribute(string fieldName) + : this(false, Validate.NotNullOrEmpty(nameof(fieldName), fieldName)) + { + } + + /// + /// Initializes a new instance of the class. + /// The name of the query field to extract will be equal to the name of the parameter + /// carrying this attribute. + /// + /// If set to , a 400 Bad Request + /// response will be sent to the client if no values are found for the field; if set to + /// , a default value will be assumed. + public QueryFieldAttribute(bool badRequestIfMissing) + : this(badRequestIfMissing, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the query field to extract. + /// is . + /// is the empty string (""). + /// If set to , a 400 Bad Request + /// response will be sent to the client if no values are found for the field; if set to + /// , a default value will be assumed. + public QueryFieldAttribute(string fieldName, bool badRequestIfMissing) + : this(badRequestIfMissing, Validate.NotNullOrEmpty(nameof(fieldName), fieldName)) + { + } + + private QueryFieldAttribute(bool badRequestIfMissing, string fieldName) + { + BadRequestIfMissing = badRequestIfMissing; + FieldName = fieldName; + } + + /// + /// Gets the name of the query field that this attribute will extract, + /// or if the name of the parameter carrying this + /// attribute is to be used as field name. + /// + public string FieldName { get; } + + /// + /// Gets or sets a value indicating whether to send a 400 Bad Request response + /// to the client if the URL query contains no values for the field. + /// If this property is and the URL query + /// contains no values for the field, the 400 Bad Request response sent + /// to the client will contain a reference to the missing field. + /// If this property is and the URL query + /// contains no values for the field, the default value for the parameter + /// (or a zero-length array if the parameter is of an array type) + /// will be passed to the controller method. + /// + public bool BadRequestIfMissing { get; } + + Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + string parameterName) + { + var data = controller.HttpContext.GetRequestQueryData(); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing query field {fieldName}."); + + return Task.FromResult(data.GetValues(fieldName)?.LastOrDefault()); + } + + Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + string parameterName) + { + var data = controller.HttpContext.GetRequestQueryData(); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing query field {fieldName}."); + + return Task.FromResult(data.GetValues(fieldName) ?? Array.Empty()); + } + + Task IRequestDataAttribute.GetRequestDataAsync( + WebApiController controller, + Type type, + string parameterName) + { + var data = controller.HttpContext.GetRequestQueryData(); + + var fieldName = FieldName ?? parameterName; + if (!data.ContainsKey(fieldName) && BadRequestIfMissing) + throw HttpException.BadRequest($"Missing query field {fieldName}."); + + if (type.IsArray) + { + var fieldValues = data.GetValues(fieldName) ?? Array.Empty(); + if (!FromString.TryConvertTo(type, fieldValues, out var result)) + throw HttpException.BadRequest($"Cannot convert field {fieldName} to an array of {type.GetElementType().Name}."); + + return Task.FromResult(result); + } + else + { + var fieldValue = data.GetValues(fieldName)?.LastOrDefault(); + if (fieldValue == null) + return Task.FromResult(type.IsValueType ? Activator.CreateInstance(type) : null); + + if (!FromString.TryConvertTo(type, fieldValue, out var result)) + throw HttpException.BadRequest($"Cannot convert field {fieldName} to {type.Name}."); + + return Task.FromResult(result); + } + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/WebApiController.cs b/src/EmbedIO/WebApi/WebApiController.cs new file mode 100644 index 000000000..35eeb9231 --- /dev/null +++ b/src/EmbedIO/WebApi/WebApiController.cs @@ -0,0 +1,73 @@ +using System.Security.Principal; +using System.Threading; +using EmbedIO.Routing; +using EmbedIO.Sessions; + +namespace EmbedIO.WebApi +{ + /// + /// Inherit from this class and define your own Web API methods + /// You must RegisterController in the Web API Module to make it active. + /// + public abstract class WebApiController + { + /// + /// Initializes a new instance of the class. + /// + protected WebApiController() + { + } + + /// + /// Gets the HTTP context. + /// This property is automatically initialized upon controller creation. + /// + public IHttpContext HttpContext { get; internal set; } + + /// + /// Gets the resolved route. + /// This property is automatically initialized upon controller creation. + /// + public RouteMatch Route { get; internal set; } + + /// + /// Gets the used to cancel processing of the request. + /// + public CancellationToken CancellationToken => HttpContext.CancellationToken; + + /// + /// Gets the HTTP request. + /// + public IHttpRequest Request => HttpContext.Request; + + /// + /// Gets the HTTP response object. + /// + public IHttpResponse Response => HttpContext.Response; + + /// + /// Gets the user. + /// + public IPrincipal User => HttpContext.User; + + /// + /// Gets the session proxy associated with the HTTP context. + /// + public ISessionProxy Session => HttpContext.Session; + + /// + /// This method is meant to be called internally by EmbedIO. + /// Derived classes can override the method + /// to perform common operations before any handler gets called. + /// + /// + public void PreProcessRequest() => OnBeforeHandler(); + + /// + /// Called before a handler to perform common operations. + /// The default behavior is to set response headers + /// in order to prevent caching of the response. + /// + protected virtual void OnBeforeHandler() => HttpContext.Response.DisableCaching(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/WebApiModule.cs b/src/EmbedIO/WebApi/WebApiModule.cs new file mode 100644 index 000000000..23d5c7d6f --- /dev/null +++ b/src/EmbedIO/WebApi/WebApiModule.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.Utilities; + +namespace EmbedIO.WebApi +{ + /// + /// A module using class methods as handlers. + /// Public instance methods that match the WebServerModule.ResponseHandler signature, and have the WebApi handler attribute + /// will be used to respond to web server requests. + /// + public class WebApiModule : WebApiModuleBase + { + /// + /// Initializes a new instance of the class, + /// using the default response serializer. + /// + /// The base URL path served by this module. + /// + /// + public WebApiModule(string baseRoute) + : base(baseRoute) + { + } + + /// + /// Initializes a new instance of the class, + /// using the specified response serializer. + /// + /// The base URL path served by this module. + /// A used to serialize + /// the result of controller methods returning + /// or Task<object>. + /// is . + /// + /// + public WebApiModule(string baseRoute, ResponseSerializerCallback serializer) + : base(baseRoute, serializer) + { + } + + /// + /// Registers a controller type using a constructor. + /// See + /// for further information. + /// + /// The type of the controller. + /// + /// + /// + public void RegisterController() + where TController : WebApiController, new() + => RegisterControllerType(typeof(TController)); + + /// + /// Registers a controller type using a factory method. + /// See + /// for further information. + /// + /// The type of the controller. + /// The factory method used to construct instances of . + /// + /// + /// + public void RegisterController(Func factory) + where TController : WebApiController + => RegisterControllerType(typeof(TController), factory); + + /// + /// Registers a controller type using a constructor. + /// See + /// for further information. + /// + /// The type of the controller. + /// + /// + /// + public void RegisterController(Type controllerType) + => RegisterControllerType(controllerType); + + /// + /// Registers a controller type using a factory method. + /// See + /// for further information. + /// + /// The type of the controller. + /// The factory method used to construct instances of . + /// + /// + /// + public void RegisterController(Type controllerType, Func factory) + => RegisterControllerType(controllerType, factory); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/WebApiModuleBase.cs b/src/EmbedIO/WebApi/WebApiModuleBase.cs new file mode 100644 index 000000000..b33036418 --- /dev/null +++ b/src/EmbedIO/WebApi/WebApiModuleBase.cs @@ -0,0 +1,599 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using Swan; + +namespace EmbedIO.WebApi +{ + /// + /// A module using objects derived from + /// as collections of handler methods. + /// + public abstract class WebApiModuleBase : RoutingModuleBase + { + private const string GetRequestDataAsyncMethodName = nameof(IRequestDataAttribute.GetRequestDataAsync); + + private static readonly MethodInfo PreProcessRequestMethod = typeof(WebApiController).GetMethod(nameof(WebApiController.PreProcessRequest)); + private static readonly MethodInfo HttpContextSetter = typeof(WebApiController).GetProperty(nameof(WebApiController.HttpContext)).GetSetMethod(true); + private static readonly MethodInfo RouteSetter = typeof(WebApiController).GetProperty(nameof(WebApiController.Route)).GetSetMethod(true); + private static readonly MethodInfo AwaitResultMethod = typeof(WebApiModuleBase).GetMethod(nameof(AwaitResult), BindingFlags.Static | BindingFlags.NonPublic); + private static readonly MethodInfo AwaitAndCastResultMethod = typeof(WebApiModuleBase).GetMethod(nameof(AwaitAndCastResult), BindingFlags.Static | BindingFlags.NonPublic); + private static readonly MethodInfo DisposeMethod = typeof(IDisposable).GetMethod(nameof(IDisposable.Dispose)); + private static readonly MethodInfo SerializeResultAsyncMethod = typeof(WebApiModuleBase).GetMethod(nameof(SerializeResultAsync), BindingFlags.Instance | BindingFlags.NonPublic); + + private readonly HashSet _controllerTypes = new HashSet(); + + /// + /// Initializes a new instance of the class, + /// using the default response serializer. + /// + /// The base route served by this module. + /// + /// + protected WebApiModuleBase(string baseRoute) + : this(baseRoute, ResponseSerializer.Default) + { + } + + /// + /// Initializes a new instance of the class, + /// using the specified response serializer. + /// + /// The base route served by this module. + /// A used to serialize + /// the result of controller methods returning + /// or Task<object>. + /// is . + /// + /// + protected WebApiModuleBase(string baseRoute, ResponseSerializerCallback serializer) + : base(baseRoute) + { + Serializer = Validate.NotNull(nameof(serializer), serializer); + } + + /// + /// A used to serialize + /// the result of controller methods returning values. + /// + public ResponseSerializerCallback Serializer { get; } + + /// + /// Gets the number of controller types registered in this module. + /// + public int ControllerCount => _controllerTypes.Count; + + /// + /// Registers a controller type using a constructor. + /// In order for registration to be successful, the specified controller type: + /// + /// must be a subclass of ; + /// must not be an abstract class; + /// must not be a generic type definition; + /// must have a public parameterless constructor. + /// + /// + /// The type of the controller. + /// The module's configuration is locked. + /// + /// is already registered in this module. + /// does not satisfy the prerequisites + /// listed in the Summary section. + /// + /// + /// A new instance of will be created + /// for each request to handle, and dereferenced immediately afterwards, + /// to be collected during next garbage collection cycle. + /// is not required to be thread-safe, + /// as it will be constructed and used in the same synchronization context. + /// However, since request handling is asynchronous, the actual execution thread + /// may vary during execution. Care must be exercised when using thread-sensitive + /// resources or thread-static data. + /// If implements , + /// its Dispose method will be called when it has + /// finished handling a request. + /// + /// + /// + protected void RegisterControllerType() + where TController : WebApiController, new() + => RegisterControllerType(typeof(TController)); + + /// + /// Registers a controller type using a factory method. + /// In order for registration to be successful: + /// + /// must be a subclass of ; + /// must not be a generic type definition; + /// 's return type must be either + /// or a subclass of . + /// + /// + /// The type of the controller. + /// The factory method used to construct instances of . + /// The module's configuration is locked. + /// is . + /// + /// is already registered in this module. + /// - or - + /// does not satisfy the prerequisites listed in the Summary section. + /// + /// + /// will be called once for each request to handle + /// in order to obtain an instance of . + /// The returned instance will be dereferenced immediately after handling the request. + /// is not required to be thread-safe, + /// as it will be constructed and used in the same synchronization context. + /// However, since request handling is asynchronous, the actual execution thread + /// may vary during execution. Care must be exercised when using thread-sensitive + /// resources or thread-static data. + /// If implements , + /// its Dispose method will be called when it has + /// finished handling a request. In this case it is recommended that + /// return a newly-constructed instance of + /// at each invocation. + /// If does not implement , + /// may employ techniques such as instance pooling to avoid + /// the overhead of constructing a new instance of + /// at each invocation. If so, resources such as file handles, database connections, etc. + /// should be freed before returning from each handler method to avoid + /// starvation. + /// + /// + /// + protected void RegisterControllerType(Func factory) + where TController : WebApiController + => RegisterControllerType(typeof(TController), factory); + + /// + /// Registers a controller type using a constructor. + /// In order for registration to be successful, the specified : + /// + /// must be a subclass of ; + /// must not be an abstract class; + /// must not be a generic type definition; + /// must have a public parameterless constructor. + /// + /// + /// The type of the controller. + /// The module's configuration is locked. + /// is . + /// + /// is already registered in this module. + /// - or - + /// does not satisfy the prerequisites + /// listed in the Summary section. + /// + /// + /// A new instance of will be created + /// for each request to handle, and dereferenced immediately afterwards, + /// to be collected during next garbage collection cycle. + /// is not required to be thread-safe, + /// as it will be constructed and used in the same synchronization context. + /// However, since request handling is asynchronous, the actual execution thread + /// may vary during execution. Care must be exercised when using thread-sensitive + /// resources or thread-static data. + /// If implements , + /// its Dispose method will be called when it has + /// finished handling a request. + /// + /// + /// + protected void RegisterControllerType(Type controllerType) + { + EnsureConfigurationNotLocked(); + + controllerType = ValidateControllerType(nameof(controllerType), controllerType, false); + + var constructor = controllerType.GetConstructors().FirstOrDefault(c => c.GetParameters().Length == 0); + if (constructor == null) + { + throw new ArgumentException( + "Controller type must have a public parameterless constructor.", + nameof(controllerType)); + } + + RegisterControllerTypeCore(controllerType, Expression.New(constructor)); + } + + /// + /// Registers a controller type using a factory method. + /// In order for registration to be successful: + /// + /// must be a subclass of ; + /// must not be a generic type definition; + /// 's return type must be either + /// or a subclass of . + /// + /// + /// The type of the controller. + /// The factory method used to construct instances of . + /// The module's configuration is locked. + /// + /// is . + /// - or - + /// is . + /// + /// + /// is already registered in this module. + /// - or - + /// One or more parameters do not satisfy the prerequisites listed in the Summary section. + /// + /// + /// will be called once for each request to handle + /// in order to obtain an instance of . + /// The returned instance will be dereferenced immediately after handling the request. + /// is not required to be thread-safe, + /// as it will be constructed and used in the same synchronization context. + /// However, since request handling is asynchronous, the actual execution thread + /// may vary during execution. Care must be exercised when using thread-sensitive + /// resources or thread-static data. + /// If implements , + /// its Dispose method will be called when it has + /// finished handling a request. In this case it is recommended that + /// return a newly-constructed instance of + /// at each invocation. + /// If does not implement , + /// may employ techniques such as instance pooling to avoid + /// the overhead of constructing a new instance of + /// at each invocation. If so, resources such as file handles, database connections, etc. + /// should be freed before returning from each handler method to avoid + /// starvation. + /// + /// + /// + protected void RegisterControllerType(Type controllerType, Func factory) + { + EnsureConfigurationNotLocked(); + + controllerType = ValidateControllerType(nameof(controllerType), controllerType, true); + factory = Validate.NotNull(nameof(factory), factory); + if (!controllerType.IsAssignableFrom(factory.Method.ReturnType)) + throw new ArgumentException("Factory method has an incorrect return type.", nameof(factory)); + + RegisterControllerTypeCore(controllerType, Expression.Call( + factory.Target == null ? null : Expression.Constant(factory.Target), + factory.Method)); + } + + private static int IndexOfRouteParameter(RouteMatcher matcher, string name) + { + var names = matcher.ParameterNames; + for (var i = 0; i < names.Count; i++) + { + if (names[i] == name) + return i; + } + + return -1; + } + + // Compile a handler. + // + // Parameters: + // - factoryExpression is an Expression that builds a controller; + // - method is a MethodInfo for a public instance method of the controller; + // - route is the route to which the controller method is associated. + // + // This method builds a lambda, with the same signature as a RouteHandlerCallback, that: + // - uses factoryExpression to build a controller; + // - calls the controller method, passing converted route parameters for method parameters with matching names + // and default values for other parameters; + // - serializes the returned object (or the result of the returned task), + // unless the return type of the controller method is void or Task; + // - if the controller implements IDisposable, disposes it. + private RouteHandlerCallback CompileHandler(Expression factoryExpression, MethodInfo method, string route) + { + // Parse the route + var matcher = RouteMatcher.Parse(route, false); + + // Lambda parameters + var contextInLambda = Expression.Parameter(typeof(IHttpContext), "context"); + var routeInLambda = Expression.Parameter(typeof(RouteMatch), "route"); + + // Local variables + var locals = new List(); + + // Local variable for controller + var controllerType = method.ReflectedType; + var controller = Expression.Variable(controllerType, "controller"); + locals.Add(controller); + + // Label for return statement + var returnTarget = Expression.Label(typeof(Task)); + + // Contents of lambda body + var bodyContents = new List(); + + // Build lambda arguments + var parameters = method.GetParameters(); + var parameterCount = parameters.Length; + var handlerArguments = new List(); + for (var i = 0; i < parameterCount; i++) + { + var parameter = parameters[i]; + var parameterType = parameter.ParameterType; + var failedToUseRequestDataAttributes = false; + + // First, check for generic request data interfaces in attributes + var requestDataInterfaces = parameter.GetCustomAttributes() + .Aggregate(new List<(Attribute Attr, Type Intf)>(), (list, attr) => { + list.AddRange(attr.GetType().GetInterfaces() + .Where(x => x.IsConstructedGenericType + && x.GetGenericTypeDefinition() == typeof(IRequestDataAttribute<,>)) + .Select(x => (attr, x))); + + return list; + }); + + // If there are any... + if (requestDataInterfaces.Count > 0) + { + // Take the first that applies to both controller and parameter type + var (attr, intf) = requestDataInterfaces.FirstOrDefault( + x => x.Intf.GenericTypeArguments[0].IsAssignableFrom(controllerType) + && parameterType.IsAssignableFrom(x.Intf.GenericTypeArguments[1])); + + if (attr != null) + { + // Use the request data interface to get a value for the parameter. + Expression useRequestDataInterface = Expression.Call( + Expression.Constant(attr), + intf.GetMethod(GetRequestDataAsyncMethodName), + controller, + Expression.Constant(parameter.Name)); + + // We should await the call to GetRequestDataAsync. + // For lack of a better way, call AwaitResult with an appropriate type argument. + useRequestDataInterface = Expression.Call( + AwaitResultMethod.MakeGenericMethod(intf.GenericTypeArguments[1]), + useRequestDataInterface); + + handlerArguments.Add(useRequestDataInterface); + continue; + } + + // If there is no interface to use, the user expects data to be injected + // but provided no way of injecting the right data type. + failedToUseRequestDataAttributes = true; + } + + // Check for non-generic request data interfaces in attributes + requestDataInterfaces = parameter.GetCustomAttributes() + .Aggregate(new List<(Attribute Attr, Type Intf)>(), (list, attr) => { + list.AddRange(attr.GetType().GetInterfaces() + .Where(x => x.IsConstructedGenericType + && x.GetGenericTypeDefinition() == typeof(IRequestDataAttribute<>)) + .Select(x => (attr, x))); + + return list; + }); + + // If there are any... + if (requestDataInterfaces.Count > 0) + { + // Take the first that applies to the controller + var (attr, intf) = requestDataInterfaces.FirstOrDefault( + x => x.Intf.GenericTypeArguments[0].IsAssignableFrom(controllerType)); + + if (attr != null) + { + // Use the request data interface to get a value for the parameter. + Expression useRequestDataInterface = Expression.Call( + Expression.Constant(attr), + intf.GetMethod(GetRequestDataAsyncMethodName), + controller, + Expression.Constant(parameterType), + Expression.Constant(parameter.Name)); + + // We should await the call to GetRequestDataAsync, + // then cast the result to the parameter type. + // For lack of a better way to do the former, + // and to save one function call, + // just call AwaitAndCastResult with an appropriate type argument. + useRequestDataInterface = Expression.Call( + AwaitAndCastResultMethod.MakeGenericMethod(parameterType), + Expression.Constant(parameter.Name), + useRequestDataInterface); + + handlerArguments.Add(useRequestDataInterface); + continue; + } + + // If there is no interface to use, the user expects data to be injected + // but provided no way of injecting the right data type. + failedToUseRequestDataAttributes = true; + } + + // There are request data attributes, but none is suitable + // for the type of the parameter. + if (failedToUseRequestDataAttributes) + throw new InvalidOperationException($"No request data attribute for parameter {parameter.Name} of method {controllerType.Name}.{method.Name} can provide the expected data type."); + + // Check whether the name of the handler parameter matches the name of a route parameter. + var index = IndexOfRouteParameter(matcher, parameter.Name); + if (index >= 0) + { + // Convert the parameter to the handler's parameter type. + var convertFromRoute = FromString.ConvertExpressionTo( + parameterType, + Expression.Property(routeInLambda, "Item", Expression.Constant(index))); + + handlerArguments.Add(convertFromRoute); + continue; + } + + // No route parameter has the same name as a handler parameter. + // Pass the default for the parameter type. + handlerArguments.Add(Expression.Constant(parameter.HasDefaultValue + ? parameter.DefaultValue + : parameterType.IsValueType + ? Activator.CreateInstance(parameterType) + : null)); + } + + // Create the controller and initialize its properties + bodyContents.Add(Expression.Assign(controller,factoryExpression)); + bodyContents.Add(Expression.Call(controller, HttpContextSetter, contextInLambda)); + bodyContents.Add(Expression.Call(controller, RouteSetter, routeInLambda)); + + // Build the handler method call + Expression callMethod = Expression.Call(controller, method, handlerArguments); + var methodReturnType = method.ReturnType; + if (methodReturnType == typeof(Task)) + { + // Nothing to do + } + else if (methodReturnType == typeof(void)) + { + // Convert void to Task by evaluating Task.CompletedTask + callMethod = Expression.Block(typeof(Task), callMethod, Expression.Constant(Task.CompletedTask)); + } + else if (IsGenericTaskType(methodReturnType, out var resultType)) + { + // Return a Task that serializes the result of a Task + callMethod = Expression.Call( + Expression.Constant(this), + SerializeResultAsyncMethod.MakeGenericMethod(resultType), + contextInLambda, + callMethod); + } + else + { + // Return a Task that serializes a result obtained synchronously + callMethod = Expression.Call( + Serializer.Target == null ? null : Expression.Constant(Serializer.Target), + Serializer.Method, + contextInLambda, + Expression.Convert(callMethod, typeof(object))); + } + + // Operations to perform on the controller. + // Pseudocode: + // controller.PreProcessRequest(); + // return controller.method(handlerArguments); + Expression workWithController = Expression.Block( + Expression.Call(controller, PreProcessRequestMethod), + Expression.Return(returnTarget, callMethod)); + + // If the controller type implements IDisposable, + // wrap operations in a simulated using block. + if (typeof(IDisposable).IsAssignableFrom(controllerType)) + { + // Pseudocode: + // try + // { + // body(); + // } + // finally + // { + // (controller as IDisposable).Dispose(); + // } + workWithController = Expression.TryFinally( + workWithController, + Expression.Call(Expression.TypeAs(controller, typeof(IDisposable)), DisposeMethod)); + } + + bodyContents.Add(workWithController); + + // At the end of the lambda body is the target of return statements. + bodyContents.Add(Expression.Label(returnTarget, Expression.Constant(Task.FromResult(false)))); + + // Build and compile the lambda. + return Expression.Lambda( + Expression.Block(locals, bodyContents), + contextInLambda, + routeInLambda) + .Compile(); + } + + private static T AwaitResult(Task task) => task.ConfigureAwait(false).GetAwaiter().GetResult(); + + private static T AwaitAndCastResult(string parameterName, Task task) + { + var result = task.ConfigureAwait(false).GetAwaiter().GetResult(); + switch (result) + { + case null when typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) == null: + throw new InvalidCastException($"Cannot cast null to {typeof(T).FullName} for parameter \"{parameterName}\"."); + case null: + return default; + case T castResult: + return castResult; + default: + throw new InvalidCastException($"Cannot cast {result.GetType().FullName} to {typeof(T).FullName} for parameter \"{parameterName}\"."); + } + } + + private async Task SerializeResultAsync(IHttpContext context, Task task) + { + await Serializer( + context, + await task.ConfigureAwait(false)).ConfigureAwait(false); + } + + private Type ValidateControllerType(string argumentName, Type value, bool canBeAbstract) + { + value = Validate.NotNull(argumentName, value); + if (canBeAbstract) + { + if (value.IsGenericTypeDefinition + || !value.IsSubclassOf(typeof(WebApiController))) + throw new ArgumentException($"Controller type must be a subclass of {nameof(WebApiController)}.", argumentName); + } + else + { + if (value.IsAbstract + || value.IsGenericTypeDefinition + || !value.IsSubclassOf(typeof(WebApiController))) + throw new ArgumentException($"Controller type must be a non-abstract subclass of {nameof(WebApiController)}.", argumentName); + } + + if (_controllerTypes.Contains(value)) + throw new ArgumentException("Controller type is already registered in this module.", argumentName); + + return value; + } + + private void RegisterControllerTypeCore(Type controllerType, Expression factoryExpression) + { + var methods = controllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(m => !m.ContainsGenericParameters); + + foreach (var method in methods) + { + var attributes = method.GetCustomAttributes(typeof(RouteAttribute)) + .OfType() + .ToArray(); + if (attributes.Length < 1) + continue; + + foreach (var attribute in attributes) + { + AddHandler(attribute.Verb, attribute.Route, CompileHandler(factoryExpression, method, attribute.Route)); + } + } + + _controllerTypes.Add(controllerType); + } + + private static bool IsGenericTaskType(Type type, out Type resultType) + { + resultType = null; + + if (!type.IsConstructedGenericType) + return false; + + if (type.GetGenericTypeDefinition() != typeof(Task<>)) + return false; + + resultType = type.GetGenericArguments()[0]; + return true; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebApi/WebApiModuleExtensions.cs b/src/EmbedIO/WebApi/WebApiModuleExtensions.cs new file mode 100644 index 000000000..c63b3b877 --- /dev/null +++ b/src/EmbedIO/WebApi/WebApiModuleExtensions.cs @@ -0,0 +1,82 @@ +using System; + +namespace EmbedIO.WebApi +{ + /// + /// Provides extension methods for . + /// + public static class WebApiModuleExtensions + { + /// + /// Registers a controller type using a constructor. + /// See + /// for further information. + /// + /// The type of the controller. + /// The on which this method is called. + /// with the controller type registered. + /// + /// + /// + public static WebApiModule WithController(this WebApiModule @this) + where TController : WebApiController, new() + { + @this.RegisterController(); + return @this; + } + + /// + /// Registers a controller type using a factory method. + /// See + /// for further information. + /// + /// The type of the controller. + /// The on which this method is called. + /// The factory method used to construct instances of . + /// with the controller type registered. + /// + /// + /// + public static WebApiModule WithController(this WebApiModule @this, Func factory) + where TController : WebApiController + { + @this.RegisterController(factory); + return @this; + } + + /// + /// Registers a controller type using a constructor. + /// See + /// for further information. + /// + /// The on which this method is called. + /// The type of the controller. + /// with the controller type registered. + /// + /// + /// + public static WebApiModule WithController(this WebApiModule @this, Type controllerType) + { + @this.RegisterController(controllerType); + return @this; + } + + /// + /// Registers a controller type using a factory method. + /// See + /// for further information. + /// + /// The on which this method is called. + /// The type of the controller. + /// The factory method used to construct instances of . + /// with the controller type registered. + /// + /// + /// + public static WebApiModule WithController(this WebApiModule @this, Type controllerType, Func factory) + { + @this.RegisterController(controllerType, factory); + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleBase.cs b/src/EmbedIO/WebModuleBase.cs new file mode 100644 index 000000000..1223f100a --- /dev/null +++ b/src/EmbedIO/WebModuleBase.cs @@ -0,0 +1,152 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Internal; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using Swan.Configuration; + +namespace EmbedIO +{ + /// + /// Base class to define web modules. + /// Although it is not required that a module inherits from this class, + /// it provides some useful features: + /// + /// validation and immutability of the property, + /// which are of paramount importance for the correct functioning of a web server; + /// support for configuration locking upon web server startup + /// (see the property + /// and the method); + /// a basic implementation of the method + /// for modules that do not need to do anything upon web server startup; + /// implementation of the callback property. + /// + /// + public abstract class WebModuleBase : ConfiguredObject, IWebModule + { + private ExceptionHandlerCallback _onUnhandledException; + private HttpExceptionHandlerCallback _onHttpException; + private RouteMatcher _routeMatcher; + + /// + /// Initializes a new instance of the class. + /// + /// The base route served by this module. + /// is . + /// is not a valid base route. + /// + /// + protected WebModuleBase(string baseRoute) + { + BaseRoute = Validate.Route(nameof(baseRoute), baseRoute, true); + _routeMatcher = RouteMatcher.Parse(baseRoute, true); + LogSource = GetType().Name; + } + + /// + public string BaseRoute { get; } + + /// + /// The module's configuration is locked. + public ExceptionHandlerCallback OnUnhandledException + { + get => _onUnhandledException; + set + { + EnsureConfigurationNotLocked(); + _onUnhandledException = value; + } + } + + /// + /// The module's configuration is locked. + public HttpExceptionHandlerCallback OnHttpException + { + get => _onHttpException; + set + { + EnsureConfigurationNotLocked(); + _onHttpException = value; + } + } + + /// + public abstract bool IsFinalHandler { get; } + + /// + /// + /// The module's configuration is locked before returning from this method. + /// + public void Start(CancellationToken cancellationToken) + { + OnStart(cancellationToken); + LockConfiguration(); + } + + /// + public RouteMatch MatchUrlPath(string urlPath) => _routeMatcher.Match(urlPath); + + /// + public async Task HandleRequestAsync(IHttpContext context) + { + var contextImpl = context as IHttpContextImpl; + var mimeTypeProvider = this as IMimeTypeProvider; + if (mimeTypeProvider != null) + contextImpl?.MimeTypeProviders.Push(mimeTypeProvider); + + try + { + await OnRequestAsync(context).ConfigureAwait(false); + if (IsFinalHandler) + context.SetHandled(); + } + catch (RequestHandlerPassThroughException) + { } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) + { + throw; // Let the web server handle it + } + catch (HttpListenerException) + { + throw; // Let the web server handle it + } + catch (Exception exception) when (exception is IHttpException) + { + await HttpExceptionHandler.Handle(LogSource, context, exception, _onHttpException) + .ConfigureAwait(false); + } + catch (Exception exception) + { + await ExceptionHandler.Handle(LogSource, context, exception, _onUnhandledException) + .ConfigureAwait(false); + } + finally + { + if (mimeTypeProvider != null) + contextImpl?.MimeTypeProviders.Pop(); + } + } + + /// + /// Gets a string to use as a source for log messages. + /// + protected string LogSource { get; } + + /// + /// Called to handle a request from a client. + /// + /// The context of the request being handled. + /// A representing the ongoing operation. + protected abstract Task OnRequestAsync(IHttpContext context); + + /// + /// Called when a module is started, immediately before locking the module's configuration. + /// + /// A used to stop the web server. + protected virtual void OnStart(CancellationToken cancellationToken) + { + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions-Actions.cs b/src/EmbedIO/WebModuleContainerExtensions-Actions.cs new file mode 100644 index 000000000..d09cb334c --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions-Actions.cs @@ -0,0 +1,312 @@ +using System; +using EmbedIO.Actions; +using EmbedIO.Utilities; +using Swan; +using Swan.Collections; + +namespace EmbedIO +{ + partial class WebModuleContainerExtensions + { + /// + /// Creates an instance of and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The HTTP verb that will be served by . + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer WithAction(this TContainer @this, string baseRoute, HttpVerbs verb, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + { + @this.Modules.Add(new ActionModule(baseRoute, verb, handler)); + return @this; + } + + /// + /// Creates an instance of with a base URL path of "/" + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The HTTP verb that will be served by . + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer WithAction(this TContainer @this, HttpVerbs verb, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, verb, handler); + + /// + /// Creates an instance of that intercepts all requests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnAny(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Any, handler); + + /// + /// Creates an instance of that intercepts all requests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnAny(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Any, handler); + + /// + /// Creates an instance of that intercepts all DELETErequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnDelete(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Delete, handler); + + /// + /// Creates an instance of that intercepts all DELETErequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnDelete(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Delete, handler); + + /// + /// Creates an instance of that intercepts all GETrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnGet(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Get, handler); + + /// + /// Creates an instance of that intercepts all GETrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnGet(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Get, handler); + + /// + /// Creates an instance of that intercepts all HEADrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnHead(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Head, handler); + + /// + /// Creates an instance of that intercepts all HEADrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnHead(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Head, handler); + + /// + /// Creates an instance of that intercepts all OPTIONSrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnOptions(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Options, handler); + + /// + /// Creates an instance of that intercepts all OPTIONSrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnOptions(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Options, handler); + + /// + /// Creates an instance of that intercepts all PATCHrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPatch(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Patch, handler); + + /// + /// Creates an instance of that intercepts all PATCHrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPatch(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Patch, handler); + + /// + /// Creates an instance of that intercepts all POSTrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPost(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Post, handler); + + /// + /// Creates an instance of that intercepts all POSTrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPost(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Post, handler); + + /// + /// Creates an instance of that intercepts all PUTrequests + /// under the specified and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPut(this TContainer @this, string baseRoute, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, baseRoute, HttpVerbs.Put, handler); + + /// + /// Creates an instance of that intercepts all PUTrequests + /// and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The callback used to handle requests. + /// with a added. + /// is . + /// + /// + /// + public static TContainer OnPut(this TContainer @this, RequestHandlerCallback handler) + where TContainer : class, IWebModuleContainer + => WithAction(@this, UrlPath.Root, HttpVerbs.Put, handler); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions-Cors.cs b/src/EmbedIO/WebModuleContainerExtensions-Cors.cs new file mode 100644 index 000000000..f68489230 --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions-Cors.cs @@ -0,0 +1,53 @@ +using System; +using EmbedIO.Cors; +using EmbedIO.Utilities; +using Swan; + +namespace EmbedIO +{ + partial class WebModuleContainerExtensions + { + /// + /// Creates an instance of and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The valid origins. Default is "*", meaning all origins. + /// The valid headers. Default is "*", meaning all headers. + /// The valid method. Default is "*", meaning all methods. + /// with a added. + /// is . + /// + public static TContainer WithCors( + this TContainer @this, + string baseRoute, + string origins, + string headers, + string methods) + where TContainer : class, IWebModuleContainer + { + @this.Modules.Add(new CorsModule(baseRoute, origins, headers, methods)); + return @this; + } + + /// + /// Creates an instance of and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The valid origins. Default is "*", meaning all origins. + /// The valid headers. Default is "*", meaning all headers. + /// The valid method. Default is "*", meaning all methods. + /// with a added. + /// is . + /// + public static TContainer WithCors( + this TContainer @this, + string origins = CorsModule.All, + string headers = CorsModule.All, + string methods = CorsModule.All) + where TContainer : class, IWebModuleContainer + => WithCors(@this, UrlPath.Root, origins, headers, methods); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions-Files.cs b/src/EmbedIO/WebModuleContainerExtensions-Files.cs new file mode 100644 index 000000000..a807f7bc4 --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions-Files.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Reflection; +using EmbedIO.Files; +using Swan; +using Swan.Collections; + +namespace EmbedIO +{ + partial class WebModuleContainerExtensions + { + /// + /// Creates an instance of , uses it to initialize + /// a , and adds the latter to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The path of the directory to serve. + /// if files and directories in + /// are not expected to change during a web server's + /// lifetime; otherwise. + /// A callback used to configure the module. + /// with a added. + /// is . + /// is . + /// is not a valid local path. + /// + /// + /// + /// + public static TContainer WithStaticFolder( + this TContainer @this, + string baseRoute, + string fileSystemPath, + bool isImmutable, + Action configure = null) + where TContainer : class, IWebModuleContainer + => WithStaticFolder(@this, null, baseRoute, fileSystemPath, isImmutable, configure); + + /// + /// Creates an instance of , uses it to initialize + /// a , and adds the latter to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// The path of the directory to serve. + /// if files and directories in + /// are not expected to change during a web server's + /// lifetime; otherwise. + /// A callback used to configure the module. + /// with a added. + /// is . + /// is . + /// is not a valid local path. + /// + /// + /// + /// + public static TContainer WithStaticFolder( + this TContainer @this, + string name, + string baseRoute, + string fileSystemPath, + bool isImmutable, + Action configure = null) + where TContainer : class, IWebModuleContainer + { + var module = new FileModule(baseRoute, new FileSystemProvider(fileSystemPath, isImmutable)); + return WithModule(@this, name, module, configure); + } + + /// + /// Creates an instance of , uses it to initialize + /// a , and adds the latter to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// 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 . + /// A callback used to configure the module. + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithEmbeddedResources( + this TContainer @this, + string baseRoute, + Assembly assembly, + string pathPrefix, + Action configure = null) + where TContainer : class, IWebModuleContainer + => WithEmbeddedResources(@this, null, baseRoute, assembly, pathPrefix, configure); + + /// + /// Creates an instance of , uses it to initialize + /// a , and adds the latter to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// 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 . + /// A callback used to configure the module. + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithEmbeddedResources( + this TContainer @this, + string name, + string baseRoute, + Assembly assembly, + string pathPrefix, + Action configure = null) + where TContainer : class, IWebModuleContainer + { + var module = new FileModule(baseRoute, new ResourceFileProvider(assembly, pathPrefix)); + return WithModule(@this, name, module, configure); + } + + /// + /// Creates an instance of using a file-system path, uses it to initialize + /// a , and adds the latter to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The local path of the Zip file. + /// A callback used to configure the module. + /// with a added. + /// is . + /// + /// + /// + /// + public static TContainer WithZipFile( + this TContainer @this, + string baseRoute, + string zipFilePath, + Action configure = null) + where TContainer : class, IWebModuleContainer + => WithZipFile(@this, null, baseRoute, zipFilePath, configure); + + /// + /// Creates an instance of using a file-system path, uses it to initialize + /// a , and adds the latter to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// The zip file-system path. + /// A callback used to configure the module. + /// with a added. + /// is . + /// + /// + /// + /// + public static TContainer WithZipFile( + this TContainer @this, + string name, + string baseRoute, + string zipFilePath, + Action configure = null) + where TContainer : class, IWebModuleContainer + { + var module = new FileModule(baseRoute, new ZipFileProvider(zipFilePath)); + return WithModule(@this, name, module, configure); + } + + /// + /// Creates an instance of using a zip file as stream, uses it to initialize + /// a , and adds the latter to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// The zip file as stream. + /// A callback used to configure the module. + /// with a added. + /// is . + /// + /// + /// + /// + public static TContainer WithZipFileStream( + this TContainer @this, + string baseRoute, + Stream zipFileStream, + Action configure = null) + where TContainer : class, IWebModuleContainer + => WithZipFileStream(@this, null, baseRoute, zipFileStream, configure); + + /// + /// Creates an instance of using a zip file as stream, uses it to initialize + /// a , and adds the latter to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// The zip file as stream. + /// A callback used to configure the module. + /// with a added. + /// is . + /// + /// + /// + /// + public static TContainer WithZipFileStream( + this TContainer @this, + string name, + string baseRoute, + Stream zipFileStream, + Action configure = null) + where TContainer : class, IWebModuleContainer + { + var module = new FileModule(baseRoute, new ZipFileProvider(zipFileStream)); + return WithModule(@this, name, module, configure); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions-Routing.cs b/src/EmbedIO/WebModuleContainerExtensions-Routing.cs new file mode 100644 index 000000000..ec50c8732 --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions-Routing.cs @@ -0,0 +1,53 @@ +using EmbedIO.Routing; +using EmbedIO.Utilities; +using Swan; +using Swan.Collections; +using System; + +namespace EmbedIO +{ + partial class WebModuleContainerExtensions + { + /// + /// Creates an instance of and adds it to a module container. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithRouting(this TContainer @this, string baseRoute, Action configure) + where TContainer : class, IWebModuleContainer + => WithRouting(@this, null, baseRoute, configure); + + /// + /// Creates an instance of and adds it to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithRouting(this TContainer @this, string name, string baseRoute, Action configure) + where TContainer : class, IWebModuleContainer + { + configure = Validate.NotNull(nameof(configure), configure); + var module = new RoutingModule(baseRoute); + return WithModule(@this, name, module, configure); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions-WebApi.cs b/src/EmbedIO/WebModuleContainerExtensions-WebApi.cs new file mode 100644 index 000000000..2fa346d08 --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions-WebApi.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using EmbedIO.WebApi; +using Swan; +using Swan.Collections; + +namespace EmbedIO +{ + partial class WebModuleContainerExtensions + { + /// + /// Creates an instance of using the default response serializer + /// and adds it to a module container without giving it a name. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithWebApi(this TContainer @this, string baseRoute, Action configure) + where TContainer : class, IWebModuleContainer + => WithWebApi(@this, null, baseRoute, configure); + + /// + /// Creates an instance of using the specified response serializer + /// and adds it to a module container without giving it a name. + /// + /// The type of the module container. + /// The on which this method is called. + /// The base route of the module. + /// A used to serialize + /// the result of controller methods returning + /// or Task<object>. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// + /// + /// + /// + public static TContainer WithWebApi( + this TContainer @this, + string baseRoute, + ResponseSerializerCallback serializer, + Action configure) + where TContainer : class, IWebModuleContainer + => WithWebApi(@this, null, baseRoute, serializer, configure); + + /// + /// Creates an instance of using the default response serializer + /// and adds it to a module container, giving it the specified + /// if not + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// is . + /// + /// + /// + /// + public static TContainer WithWebApi( + this TContainer @this, + string name, + string baseRoute, + Action configure) + where TContainer : class, IWebModuleContainer + { + configure = Validate.NotNull(nameof(configure), configure); + var module = new WebApiModule(baseRoute); + return WithModule(@this, name, module, configure); + } + + /// + /// Creates an instance of , using the specified response serializer + /// and adds it to a module container, giving it the specified + /// if not + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The base route of the module. + /// A used to serialize + /// the result of controller methods returning + /// or Task<object>. + /// A callback used to configure the newly-created . + /// with a added. + /// is . + /// + /// is . + /// - or - + /// is . + /// + /// + /// + /// + /// + public static TContainer WithWebApi( + this TContainer @this, + string name, + string baseRoute, + ResponseSerializerCallback serializer, + Action configure) + where TContainer : class, IWebModuleContainer + { + configure = Validate.NotNull(nameof(configure), configure); + var module = new WebApiModule(baseRoute, serializer); + return WithModule(@this, name, module, configure); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleContainerExtensions.cs b/src/EmbedIO/WebModuleContainerExtensions.cs new file mode 100644 index 000000000..c6c8659c2 --- /dev/null +++ b/src/EmbedIO/WebModuleContainerExtensions.cs @@ -0,0 +1,84 @@ +using System; +using EmbedIO.Utilities; + +namespace EmbedIO +{ + /// + /// Contains extension methods for types implementing . + /// + public static partial class WebModuleContainerExtensions + { + /// + /// Adds the specified to a module container, without giving it a name. + /// + /// The type of the module container. + /// The on which this method is called. + /// The module. + /// with added. + /// is . + /// + /// + public static TContainer WithModule(this TContainer @this, IWebModule module) + where TContainer : class, IWebModuleContainer + => WithModule(@this, null, module); + + /// + /// Adds the specified to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The on which this method is called. + /// The name. + /// The module. + /// with added. + /// is . + /// + /// + public static TContainer WithModule(this TContainer @this, string name, IWebModule module) + where TContainer : class, IWebModuleContainer + { + @this.Modules.Add(name, module); + return @this; + } + + /// + /// Adds the specified to a module container, without giving it a name. + /// + /// The type of the module container. + /// The type of the . + /// The on which this method is called. + /// The module. + /// A callback used to configure the . + /// with added. + /// is . + /// + /// + public static TContainer WithModule(this TContainer @this, TWebModule module, Action configure) + where TContainer : class, IWebModuleContainer + where TWebModule : IWebModule + => WithModule(@this, null, module, configure); + + /// + /// Adds the specified to a module container, + /// giving it the specified if not . + /// + /// The type of the module container. + /// The type of the . + /// The on which this method is called. + /// The name. + /// The module. + /// A callback used to configure the . + /// with added. + /// is . + /// + /// + public static TContainer WithModule(this TContainer @this, string name, TWebModule module, Action configure) + where TContainer : class, IWebModuleContainer + where TWebModule : IWebModule + { + configure?.Invoke(module); + @this.Modules.Add(name, module); + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleExtensions-ExceptionHandlers.cs b/src/EmbedIO/WebModuleExtensions-ExceptionHandlers.cs new file mode 100644 index 000000000..8292e17d9 --- /dev/null +++ b/src/EmbedIO/WebModuleExtensions-ExceptionHandlers.cs @@ -0,0 +1,45 @@ +using System; + +namespace EmbedIO +{ + partial class WebModuleExtensions + { + /// + /// Sets the HTTP exception handler on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// The HTTP exception handler. + /// with the OnHttpException + /// property set to . + /// is . + /// The module's configuration is locked. + /// + /// + public static TWebModule HandleHttpException(this TWebModule @this, HttpExceptionHandlerCallback handler) + where TWebModule : IWebModule + { + @this.OnHttpException = handler; + return @this; + } + + /// + /// Sets the unhandled exception handler on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// The unhandled exception handler. + /// with the OnUnhandledException + /// property set to . + /// is . + /// The module's configuration is locked. + /// + /// + public static TWebModule HandleUnhandledException(this TWebModule @this, ExceptionHandlerCallback handler) + where TWebModule : IWebModule + { + @this.OnUnhandledException = handler; + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebModuleExtensions.cs b/src/EmbedIO/WebModuleExtensions.cs new file mode 100644 index 000000000..b691abbf6 --- /dev/null +++ b/src/EmbedIO/WebModuleExtensions.cs @@ -0,0 +1,9 @@ +namespace EmbedIO +{ + /// + /// Provides extension methods for types implementing . + /// + public static partial class WebModuleExtensions + { + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServer-Constants.cs b/src/EmbedIO/WebServer-Constants.cs new file mode 100644 index 000000000..6d8c6bc3e --- /dev/null +++ b/src/EmbedIO/WebServer-Constants.cs @@ -0,0 +1,15 @@ +using System.IO; + +namespace EmbedIO +{ + partial class WebServer + { + /// + /// The size, in bytes,of buffers used to transfer contents between streams. + /// The value of this constant is the same as the default used by the + /// method. For the reasons why this value was chosen, see + /// .NET Framework reference source. + /// + public const int StreamCopyBufferSize = 81920; + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServer.cs b/src/EmbedIO/WebServer.cs new file mode 100644 index 000000000..0df98d302 --- /dev/null +++ b/src/EmbedIO/WebServer.cs @@ -0,0 +1,196 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Net.Internal; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using Swan.Logging; + +namespace EmbedIO +{ + /// + /// EmbedIO's web server. This is the default implementation of . + /// This class also contains some useful constants related to EmbedIO's internal working. + /// + public partial class WebServer : WebServerBase + { + /// + /// Initializes a new instance of the class, + /// that will respond on HTTP port 80 on all network interfaces. + /// + public WebServer() + : this(80) + { + } + + /// + /// Initializes a new instance of the class, + /// that will respond on the specified HTTP port on all network interfaces. + /// + /// The port. + public WebServer(int port) + : this($"http://*:{port}/") + { + } + + /// + /// Initializes a new instance of the class + /// with the specified URL prefixes. + /// + /// The URL prefixes to configure. + /// is . + /// + /// One or more of the elements of is the empty string. + /// - or - + /// One or more of the elements of is already registered. + /// + public WebServer(params string[] urlPrefixes) + : this(new WebServerOptions().WithUrlPrefixes(urlPrefixes)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of HTTP listener to configure. + /// The URL prefixes to configure. + /// is . + /// + /// One or more of the elements of is the empty string. + /// - or - + /// One or more of the elements of is already registered. + /// + public WebServer(HttpListenerMode mode, params string[] urlPrefixes) + : this(new WebServerOptions().WithMode(mode).WithUrlPrefixes(urlPrefixes)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of HTTP listener to configure. + /// The X.509 certificate to use for SSL connections. + /// The URL prefixes to configure. + /// is . + /// + /// One or more of the elements of is the empty string. + /// - or - + /// One or more of the elements of is already registered. + /// + public WebServer(HttpListenerMode mode, X509Certificate2 certificate, params string[] urlPrefixes) + : this(new WebServerOptions() + .WithMode(mode) + .WithCertificate(certificate) + .WithUrlPrefixes(urlPrefixes)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A object used to configure this instance. + /// is . + public WebServer(WebServerOptions options) + : base(options) + { + Listener = CreateHttpListener(); + } + + /// + /// Initializes a new instance of the class. + /// + /// A callback that will be used to configure + /// the server's options. + /// is . + public WebServer(Action configure) + : base(configure) + { + Listener = CreateHttpListener(); + } + + /// + /// Gets the underlying HTTP listener. + /// + public IHttpListener Listener { get; } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + try + { + Listener.Dispose(); + } + catch (Exception ex) + { + ex.Log(LogSource, "Exception thrown while disposing HTTP listener."); + } + + "Listener closed.".Info(LogSource); + } + + base.Dispose(disposing); + } + + /// + protected override void Prepare(CancellationToken cancellationToken) + { + Listener.Start(); + "Started HTTP Listener".Info(LogSource); + + // close port when the cancellation token is cancelled + cancellationToken.Register(() => Listener?.Stop()); + } + + /// + protected override async Task ProcessRequestsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && (Listener?.IsListening ?? false)) + { + var context = await Listener.GetContextAsync(cancellationToken).ConfigureAwait(false); + context.CancellationToken = cancellationToken; + context.Route = RouteMatch.UnsafeFromRoot(UrlPath.Normalize(context.Request.Url.AbsolutePath, false)); + +#pragma warning disable CS4014 // Call is not awaited - of course, it has to run in parallel. + Task.Run(() => DoHandleContextAsync(context), cancellationToken); +#pragma warning restore CS4014 + } + } + + /// + protected override void OnFatalException() => Listener?.Dispose(); + + private IHttpListener CreateHttpListener() + { + IHttpListener DoCreate() + { + switch (Options.Mode) + { + case HttpListenerMode.Microsoft: + return System.Net.HttpListener.IsSupported + ? new SystemHttpListener(new System.Net.HttpListener()) as IHttpListener + : new Net.HttpListener(Options.Certificate); + default: // case HttpListenerMode.EmbedIO + return new Net.HttpListener(Options.Certificate); + } + } + + var listener = DoCreate(); + $"Running HTTPListener: {listener.Name}".Info(LogSource); + foreach (var prefix in Options.UrlPrefixes) + { + var urlPrefix = new string(prefix?.ToCharArray()); + + if (urlPrefix.EndsWith("/") == false) urlPrefix = urlPrefix + "/"; + urlPrefix = urlPrefix.ToLowerInvariant(); + + listener.AddPrefix(urlPrefix); + $"Web server prefix '{urlPrefix}' added.".Info(LogSource); + } + + return listener; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerBase`1.cs b/src/EmbedIO/WebServerBase`1.cs new file mode 100644 index 000000000..f3464c8c2 --- /dev/null +++ b/src/EmbedIO/WebServerBase`1.cs @@ -0,0 +1,347 @@ +using System; +using System.Globalization; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Internal; +using EmbedIO.Routing; +using EmbedIO.Sessions; +using EmbedIO.Utilities; +using Swan.Collections; +using Swan.Configuration; +using Swan.Logging; + +namespace EmbedIO +{ + /// + /// Base class for implementations. + /// + /// The type of the options object used to configure an instance. + /// + /// + public abstract class WebServerBase : ConfiguredObject, IWebServer, IHttpContextHandler + where TOptions : WebServerOptionsBase, new() + { + private readonly WebModuleCollection _modules; + + private readonly MimeTypeCustomizer _mimeTypeCustomizer = new MimeTypeCustomizer(); + + private ExceptionHandlerCallback _onUnhandledException = ExceptionHandler.Default; + private HttpExceptionHandlerCallback _onHttpException = HttpExceptionHandler.Default; + + private WebServerState _state = WebServerState.Created; + + private ISessionManager _sessionManager; + + /// + /// Initializes a new instance of the class. + /// + protected WebServerBase() + : this(new TOptions(), null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A instance that will be used + /// to configure the server. + /// is . + protected WebServerBase(TOptions options) + : this(Validate.NotNull(nameof(options), options), null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A callback that will be used to configure + /// the server's options. + /// is . + protected WebServerBase(Action configure) + : this(new TOptions(), Validate.NotNull(nameof(configure), configure)) + { + } + + private WebServerBase(TOptions options, Action configure) + { + Options = options; + LogSource = GetType().Name; + _modules = new WebModuleCollection(LogSource); + + configure?.Invoke(Options); + Options.Lock(); + } + + /// + /// Finalizes an instance of the class. + /// + ~WebServerBase() + { + Dispose(false); + } + + /// + public event WebServerStateChangedEventHandler StateChanged; + + /// + public IComponentCollection Modules => _modules; + + /// + /// Gets the options object used to configure this instance. + /// + public TOptions Options { get; } + + /// + /// The server's configuration is locked. + /// this property is being set to . + /// + /// The default value for this property is . + /// + /// + public ExceptionHandlerCallback OnUnhandledException + { + get => _onUnhandledException; + set + { + EnsureConfigurationNotLocked(); + _onUnhandledException = Validate.NotNull(nameof(value), value); + } + } + + /// + /// The server's configuration is locked. + /// this property is being set to . + /// + /// The default value for this property is . + /// + /// + public HttpExceptionHandlerCallback OnHttpException + { + get => _onHttpException; + set + { + EnsureConfigurationNotLocked(); + _onHttpException = Validate.NotNull(nameof(value), value); + } + } + + /// + public ISessionManager SessionManager + { + get => _sessionManager; + set + { + EnsureConfigurationNotLocked(); + _sessionManager = value; + } + } + + /// + public WebServerState State + { + get => _state; + private set + { + if (value == _state) return; + + var oldState = _state; + _state = value; + + if (_state != WebServerState.Created) + { + LockConfiguration(); + } + + StateChanged?.Invoke(this, new WebServerStateChangedEventArgs(oldState, value)); + } + } + + /// + /// Gets a string to use as a source for log messages. + /// + protected string LogSource { get; } + + /// + public Task HandleContextAsync(IHttpContextImpl context) + { + if (State > WebServerState.Listening) + throw new InvalidOperationException("The web server has already been stopped."); + + if (State < WebServerState.Listening) + throw new InvalidOperationException("The web server has not been started yet."); + + return DoHandleContextAsync(context); + } + + 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); + + /// + /// The method was already called. + /// Cancellation was requested. + public async Task RunAsync(CancellationToken cancellationToken = default) + { + try + { + State = WebServerState.Loading; + Prepare(cancellationToken); + + _sessionManager?.Start(cancellationToken); + _modules.StartAll(cancellationToken); + + State = WebServerState.Listening; + await ProcessRequestsAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + "Operation canceled.".Debug(LogSource); + } + finally + { + "Cleaning up".Info(LogSource); + State = WebServerState.Stopped; + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Asynchronously handles a received request. + /// + /// The context of the request. + /// A representing the ongoing operation. + protected async Task DoHandleContextAsync(IHttpContextImpl context) + { + context.SupportCompressedRequests = Options.SupportCompressedRequests; + context.MimeTypeProviders.Push(this); + + try + { + $"[{context.Id}] {context.Request.SafeGetRemoteEndpointStr()}: {context.Request.HttpMethod} {context.Request.Url.PathAndQuery} - {context.Request.UserAgent}" + .Debug(LogSource); + + context.Session = new SessionProxy(context, SessionManager); + try + { + if (context.CancellationToken.IsCancellationRequested) + return; + + try + { + // Return a 404 (Not Found) response if no module handled the response. + await _modules.DispatchRequestAsync(context).ConfigureAwait(false); + if (!context.IsHandled) + { + $"[{context.Id}] No module generated a response. Sending 404 - Not Found".Error(LogSource); + throw HttpException.NotFound("No module was able to serve the requested path."); + } + } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) + { + throw; // Let outer catch block handle it + } + catch (HttpListenerException) + { + throw; // Let outer catch block handle it + } + catch (Exception exception) when (exception is IHttpException) + { + await HttpExceptionHandler.Handle(LogSource, context, exception, _onHttpException) + .ConfigureAwait(false); + } + catch (Exception exception) + { + await ExceptionHandler.Handle(LogSource, context, exception, _onUnhandledException) + .ConfigureAwait(false); + } + } + finally + { + await context.Response.OutputStream.FlushAsync(context.CancellationToken) + .ConfigureAwait(false); + + var statusCode = context.Response.StatusCode; + var statusDescription = context.Response.StatusDescription; + var sendChunked = context.Response.SendChunked; + var contentLength = context.Response.ContentLength64; + context.Close(); + $"[{context.Id}] {context.Request.HttpMethod} {context.Request.Url.AbsolutePath}: \"{statusCode} {statusDescription}\" sent in {context.Age}ms ({(sendChunked ? "chunked" : contentLength.ToString(CultureInfo.InvariantCulture) + " bytes")})" + .Info(LogSource); + } + } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) + { + $"[{context.Id}] Operation canceled.".Debug(LogSource); + } + catch (HttpListenerException ex) + { + ex.Log(LogSource, $"[{context.Id}] Listener exception."); + } + catch (Exception ex) + { + ex.Log(LogSource, $"[{context.Id}] Fatal exception."); + OnFatalException(); + } + } + + /// + protected override void OnBeforeLockConfiguration() + { + base.OnBeforeLockConfiguration(); + + _mimeTypeCustomizer.Lock(); + _modules.Lock(); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + + _modules.Dispose(); + } + + /// + /// Prepares a web server for running. + /// + /// A used to stop the web server. + protected virtual void Prepare(CancellationToken cancellationToken) + { + } + + /// + /// Asynchronously receives requests and processes them. + /// + /// A used to stop the web server. + /// A representing the ongoing operation. + protected abstract Task ProcessRequestsAsync(CancellationToken cancellationToken); + + /// + /// Called when an exception is caught in the web server's request processing loop. + /// This method should tell the server socket to stop accepting further requests. + /// + protected abstract void OnFatalException(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerExtensions-ExceptionHandliers.cs b/src/EmbedIO/WebServerExtensions-ExceptionHandliers.cs new file mode 100644 index 000000000..2c36e4804 --- /dev/null +++ b/src/EmbedIO/WebServerExtensions-ExceptionHandliers.cs @@ -0,0 +1,47 @@ +using System; + +namespace EmbedIO +{ + partial class WebServerExtensions + { + /// + /// Sets the HTTP exception handler on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// The HTTP exception handler. + /// with the OnHttpException + /// property set to . + /// is . + /// The web server has already been started. + /// is . + /// + /// + public static TWebServer HandleHttpException(this TWebServer @this, HttpExceptionHandlerCallback handler) + where TWebServer : IWebServer + { + @this.OnHttpException = handler; + return @this; + } + + /// + /// Sets the unhandled exception handler on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// The unhandled exception handler. + /// with the OnUnhandledException + /// property set to . + /// is . + /// The web server has already been started. + /// is . + /// + /// + public static TWebServer HandleUnhandledException(this TWebServer @this, ExceptionHandlerCallback handler) + where TWebServer : IWebServer + { + @this.OnUnhandledException = handler; + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerExtensions-SessionManager.cs b/src/EmbedIO/WebServerExtensions-SessionManager.cs new file mode 100644 index 000000000..b5e59d0c5 --- /dev/null +++ b/src/EmbedIO/WebServerExtensions-SessionManager.cs @@ -0,0 +1,43 @@ +using System; +using EmbedIO.Sessions; + +namespace EmbedIO +{ + partial class WebServerExtensions + { + /// + /// Sets the session manager on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// The session manager. + /// with the session manager set. + /// is . + /// The web server has already been started. + public static TWebServer WithSessionManager(this TWebServer @this, ISessionManager sessionManager) + where TWebServer : IWebServer + { + @this.SessionManager = sessionManager; + return @this; + } + + /// + /// Creates a with all properties set to their default values + /// and sets it as session manager on a . + /// + /// The type of the web server. + /// The on which this method is called. + /// A callback used to configure the session manager. + /// with the session manager set. + /// is . + /// The web server has already been started. + public static TWebServer WithLocalSessionManager(this TWebServer @this, Action configure = null) + where TWebServer : IWebServer + { + var sessionManager = new LocalSessionManager(); + configure?.Invoke(sessionManager); + @this.SessionManager = sessionManager; + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerExtensions.cs b/src/EmbedIO/WebServerExtensions.cs new file mode 100644 index 000000000..43aaec26e --- /dev/null +++ b/src/EmbedIO/WebServerExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Swan; + +namespace EmbedIO +{ + /// + /// Provides extension methods for types implementing . + /// + public static partial class WebServerExtensions + { + /// + /// Starts a web server by calling + /// in another thread. + /// + /// The on which this method is called. + /// A used to stop the web server. + /// is . + /// The web server has already been started. + public static void Start(this IWebServer @this, CancellationToken cancellationToken = default) + { +#pragma warning disable CS4014 // The call is not awaited - it is expected to run in parallel. + Task.Run(() => @this.RunAsync(cancellationToken)); +#pragma warning restore CS4014 + while (@this.State < WebServerState.Listening) + Task.Delay(1, cancellationToken).Await(); + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerOptions.cs b/src/EmbedIO/WebServerOptions.cs new file mode 100644 index 000000000..a198ccc0e --- /dev/null +++ b/src/EmbedIO/WebServerOptions.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.RegularExpressions; +using EmbedIO.Utilities; +using Swan; +using Swan.Logging; + +namespace EmbedIO +{ + /// + /// Contains options for configuring an instance of . + /// + public sealed class WebServerOptions : WebServerOptionsBase + { + private const string NetShLogSource = "NetSh"; + + private readonly List _urlPrefixes = new List(); + + private HttpListenerMode _mode = HttpListenerMode.EmbedIO; + + private X509Certificate2 _certificate; + + private string _certificateThumbprint; + + private bool _autoLoadCertificate; + + private bool _autoRegisterCertificate; + + private StoreName _storeName = StoreName.My; + + private StoreLocation _storeLocation = StoreLocation.LocalMachine; + + /// + /// Initializes a new instance of the class. + /// + public WebServerOptions() + { + } + + /// + /// Gets the URL prefixes. + /// + public IReadOnlyList UrlPrefixes => _urlPrefixes; + + /// + /// Gets or sets the type of HTTP listener. + /// + /// This property is being set, + /// and this instance's configuration is locked. + /// + public HttpListenerMode Mode + { + get => _mode; + set + { + EnsureConfigurationNotLocked(); + _mode = value; + } + } + + /// + /// Gets or sets the X.509 certificate to use for SSL connections. + /// + /// This property is being set, + /// and this instance's configuration is locked. + public X509Certificate2 Certificate + { + get + { + if (AutoRegisterCertificate) + return TryRegisterCertificate() ? _certificate : null; + + return _certificate ?? (AutoLoadCertificate ? LoadCertificate() : null); + } + set + { + EnsureConfigurationNotLocked(); + _certificate = value; + } + } + + /// + /// Gets or sets the thumbprint of the X.509 certificate to use for SSL connections. + /// + /// This property is being set, + /// and this instance's configuration is locked. + public string CertificateThumbprint + { + get => _certificateThumbprint; + set + { + EnsureConfigurationNotLocked(); + + // strip any non-hexadecimal values and make uppercase + _certificateThumbprint = value == null + ? null + : Regex.Replace(value, @"[^\da-fA-F]", string.Empty).ToUpper(CultureInfo.InvariantCulture); + } + } + + /// + /// Gets or sets a value indicating whether to automatically load the X.509 certificate. + /// + /// This property is being set, + /// and this instance's configuration is locked. + /// This property is being set to + /// and the underlying operating system is not Windows. + public bool AutoLoadCertificate + { + get => _autoLoadCertificate; + set + { + EnsureConfigurationNotLocked(); + if (value && SwanRuntime.OS != Swan.OperatingSystem.Windows) + throw new PlatformNotSupportedException("AutoLoadCertificate functionality is only available under Windows."); + + _autoLoadCertificate = value; + } + } + + /// + /// Gets or sets a value indicating whether to automatically bind the X.509 certificate + /// to the port used for HTTPS. + /// + /// This property is being set, + /// and this instance's configuration is locked. + /// This property is being set to + /// and the underlying operating system is not Windows. + public bool AutoRegisterCertificate + { + get => _autoRegisterCertificate; + set + { + EnsureConfigurationNotLocked(); + if (value && SwanRuntime.OS != Swan.OperatingSystem.Windows) + throw new PlatformNotSupportedException("AutoRegisterCertificate functionality is only available under Windows."); + + _autoRegisterCertificate = value; + } + } + + /// + /// Gets or sets a value indicating the X.509 certificate store where to load the certificate from. + /// + /// This property is being set, + /// and this instance's configuration is locked. + /// + public StoreName StoreName + { + get => _storeName; + set + { + EnsureConfigurationNotLocked(); + _storeName = value; + } + } + + /// + /// Gets or sets a value indicating the location of the X.509 certificate store where to load the certificate from. + /// + /// This property is being set, + /// and this instance's configuration is locked. + /// + public StoreLocation StoreLocation + { + get => _storeLocation; + set + { + EnsureConfigurationNotLocked(); + _storeLocation = value; + } + } + + /// + /// Adds a URL prefix. + /// + /// The URL prefix. + /// This instance's configuration is locked. + /// is . + /// + /// is the empty string. + /// - or - + /// is already registered. + /// + public void AddUrlPrefix(string urlPrefix) + { + EnsureConfigurationNotLocked(); + + urlPrefix = Validate.NotNullOrEmpty(nameof(urlPrefix), urlPrefix); + if (_urlPrefixes.Contains(urlPrefix)) + throw new ArgumentException("URL prefix is already registered.", nameof(urlPrefix)); + + _urlPrefixes.Add(urlPrefix); + } + + private X509Certificate2 LoadCertificate() + { + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) + return null; + + if (!string.IsNullOrWhiteSpace(_certificateThumbprint)) return GetCertificate(_certificateThumbprint); + + var netsh = GetNetsh("show"); + + string thumbprint = null; + + netsh.ErrorDataReceived += (s, e) => + { + if (string.IsNullOrWhiteSpace(e.Data)) return; + + e.Data.Error(NetShLogSource); + }; + + netsh.OutputDataReceived += (s, e) => + { + if (string.IsNullOrWhiteSpace(e.Data)) return; + + e.Data.Debug(NetShLogSource); + + var line = e.Data.Trim(); + + if (line.StartsWith("Certificate Hash") && line.IndexOf(":", StringComparison.Ordinal) > -1) + thumbprint = line.Split(':')[1].Trim(); + }; + + if (!netsh.Start()) + return null; + + netsh.BeginOutputReadLine(); + netsh.BeginErrorReadLine(); + netsh.WaitForExit(); + + return netsh.ExitCode == 0 && !string.IsNullOrEmpty(thumbprint) + ? GetCertificate(thumbprint) + : null; + } + + private X509Certificate2 GetCertificate(string thumbprint = null) + { + using (var store = new X509Store(StoreName, StoreLocation)) + { + store.Open(OpenFlags.ReadOnly); + var signingCert = store.Certificates.Find( + X509FindType.FindByThumbprint, + thumbprint ?? _certificateThumbprint, + false); + return signingCert.Count == 0 ? null : signingCert[0]; + } + } + + private bool AddCertificateToStore() + { + using (var store = new X509Store(StoreName, StoreLocation)) + { + try + { + store.Open(OpenFlags.ReadWrite); + store.Add(_certificate); + return true; + } + catch + { + return false; + } + } + } + + private bool TryRegisterCertificate() + { + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) + return false; + + if (_certificate == null) + throw new InvalidOperationException("A certificate is required to AutoRegister"); + + if (GetCertificate(_certificate.Thumbprint) == null && !AddCertificateToStore()) + { + throw new InvalidOperationException( + "The provided certificate cannot be added to the default store, add it manually"); + } + + var netsh = GetNetsh("add", $"certhash={_certificate.Thumbprint} appid={{adaa04bb-8b63-4073-a12f-d6f8c0b4383f}}"); + + var sb = new StringBuilder(); + + void PushLine(object sender, DataReceivedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.Data)) return; + + sb.AppendLine(e.Data); + e.Data.Error(NetShLogSource); + } + + netsh.OutputDataReceived += PushLine; + + netsh.ErrorDataReceived += PushLine; + + if (!netsh.Start()) return false; + + netsh.BeginOutputReadLine(); + netsh.BeginErrorReadLine(); + netsh.WaitForExit(); + + return netsh.ExitCode == 0 ? true : throw new InvalidOperationException($"NetSh error: {sb}"); + } + + private int GetSslPort() + { + var port = 443; + + foreach (var url in UrlPrefixes.Where(x => + x.StartsWith("https:", StringComparison.OrdinalIgnoreCase))) + { + var match = Regex.Match(url, @":(\d+)"); + + if (match.Success && int.TryParse(match.Groups[1].Value, out port)) + break; + } + + return port; + } + + private Process GetNetsh(string verb, string options = "") => new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "netsh", + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + Arguments = $"http {verb} sslcert ipport=0.0.0.0:{GetSslPort()} {options}", + }, + }; + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerOptionsBase.cs b/src/EmbedIO/WebServerOptionsBase.cs new file mode 100644 index 000000000..dd4790ab8 --- /dev/null +++ b/src/EmbedIO/WebServerOptionsBase.cs @@ -0,0 +1,35 @@ +using System; +using Swan.Configuration; + +namespace EmbedIO +{ + /// + /// Base class for web server options. + /// + public abstract class WebServerOptionsBase : ConfiguredObject + { + private bool _supportCompressedRequests; + + /// + /// Gets or sets a value indicating whether compressed request bodies are supported. + /// The default value is , because of the security risk + /// posed by decompression bombs. + /// + /// This property is being set and this instance's + /// configuration is locked. + public bool SupportCompressedRequests + { + get => _supportCompressedRequests; + set + { + EnsureConfigurationNotLocked(); + _supportCompressedRequests = value; + } + } + + /// + /// Locks this instance, preventing further configuration. + /// + public void Lock() => LockConfiguration(); + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerOptionsBaseExtensions.cs b/src/EmbedIO/WebServerOptionsBaseExtensions.cs new file mode 100644 index 000000000..a1003064c --- /dev/null +++ b/src/EmbedIO/WebServerOptionsBaseExtensions.cs @@ -0,0 +1,27 @@ +using System; + +namespace EmbedIO +{ + /// + /// Provides extension methods for classes derived from . + /// + public static class WebServerOptionsBaseExtensions + { + /// + /// Adds a URL prefix. + /// + /// The type of the object on which this method is called. + /// The object on which this method is called. + /// If , enable support for compressed request bodies. + /// with its SupportCompressedRequests + /// property set to . + /// is . + /// The configuration of is locked. + public static TOptions WithSupportCompressedRequests(this TOptions @this, bool value) + where TOptions : WebServerOptionsBase + { + @this.SupportCompressedRequests = value; + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerOptionsExtensions.cs b/src/EmbedIO/WebServerOptionsExtensions.cs new file mode 100644 index 000000000..b68b28d8c --- /dev/null +++ b/src/EmbedIO/WebServerOptionsExtensions.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using EmbedIO.Utilities; + +namespace EmbedIO +{ + /// + /// Provides extension methods for . + /// + public static class WebServerOptionsExtensions + { + /// + /// Adds a URL prefix. + /// + /// The on which this method is called. + /// The URL prefix. + /// with added. + /// is . + /// The configuration of is locked. + /// is . + /// + /// is the empty string. + /// - or - + /// is already registered. + /// + public static WebServerOptions WithUrlPrefix(this WebServerOptions @this, string urlPrefix) + { + @this.AddUrlPrefix(urlPrefix); + return @this; + } + + /// + /// Adds zero or more URL prefixes. + /// + /// The on which this method is called. + /// An enumeration of URL prefixes to add. + /// with every non- element + /// of added. + /// is . + /// The configuration of is locked. + /// is . + /// + /// One or more of the elements of is the empty string. + /// - or - + /// One or more of the elements of is already registered. + /// + public static WebServerOptions WithUrlPrefixes(this WebServerOptions @this, IEnumerable urlPrefixes) + { + foreach (var urlPrefix in Validate.NotNull(nameof(urlPrefixes), urlPrefixes)) + @this.AddUrlPrefix(urlPrefix); + + return @this; + } + + /// + /// Adds zero or more URL prefixes. + /// + /// The on which this method is called. + /// An array of URL prefixes to add. + /// with every non- element + /// of added. + /// is . + /// The configuration of is locked. + /// is . + /// + /// One or more of the elements of is the empty string. + /// - or - + /// One or more of the elements of is already registered. + /// + public static WebServerOptions WithUrlPrefixes(this WebServerOptions @this, params string[] urlPrefixes) + => WithUrlPrefixes(@this, urlPrefixes as IEnumerable); + + /// + /// Sets the type of HTTP listener. + /// + /// The on which this method is called. + /// The type of HTTP listener. + /// with its Mode property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithMode(this WebServerOptions @this, HttpListenerMode value) + { + @this.Mode = value; + return @this; + } + + /// + /// Sets the type of HTTP listener to . + /// + /// The on which this method is called. + /// with its Mode property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithEmbedIOHttpListener(this WebServerOptions @this) + { + @this.Mode = HttpListenerMode.EmbedIO; + return @this; + } + + /// + /// Sets the type of HTTP listener to . + /// + /// The on which this method is called. + /// with its Mode property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithMicrosoftHttpListener(this WebServerOptions @this) + { + @this.Mode = HttpListenerMode.Microsoft; + return @this; + } + + /// + /// Sets the X.509 certificate to use for SSL connections. + /// + /// The on which this method is called. + /// The X.509 certificate to use for SSL connections. + /// with its Certificate property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithCertificate(this WebServerOptions @this, X509Certificate2 value) + { + @this.Certificate = value; + return @this; + } + + /// + /// Sets the thumbprint of the X.509 certificate to use for SSL connections. + /// + /// The on which this method is called. + /// The thumbprint of the X.509 certificate to use for SSL connections. + /// with its CertificateThumbprint property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithCertificateThumbprint(this WebServerOptions @this, string value) + { + @this.CertificateThumbprint = value; + return @this; + } + + /// + /// Sets a value indicating whether to automatically load the X.509 certificate. + /// + /// The on which this method is called. + /// If , automatically load the X.509 certificate. + /// with its AutoLoadCertificate property + /// set to . + /// is . + /// The configuration of is locked. + /// is + /// and the underlying operating system is not Windows. + public static WebServerOptions WithAutoLoadCertificate(this WebServerOptions @this, bool value) + { + @this.AutoLoadCertificate = value; + return @this; + } + + /// + /// Instructs a instance to automatically load the X.509 certificate. + /// + /// The on which this method is called. + /// with its AutoLoadCertificate property + /// set to . + /// is . + /// The configuration of is locked. + /// The underlying operating system is not Windows. + public static WebServerOptions WithAutoLoadCertificate(this WebServerOptions @this) + { + @this.AutoLoadCertificate = true; + return @this; + } + + /// + /// Instructs a instance to not load the X.509 certificate automatically . + /// + /// The on which this method is called. + /// with its AutoLoadCertificate property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithoutAutoLoadCertificate(this WebServerOptions @this) + { + @this.AutoLoadCertificate = false; + return @this; + } + + /// + /// Sets a value indicating whether to automatically bind the X.509 certificate + /// to the port used for HTTPS. + /// + /// The on which this method is called. + /// If , automatically bind the X.509 certificate + /// to the port used for HTTPS. + /// with its AutoRegisterCertificate property + /// set to . + /// is . + /// The configuration of is locked. + /// is + /// and the underlying operating system is not Windows. + public static WebServerOptions WithAutoRegisterCertificate(this WebServerOptions @this, bool value) + { + @this.AutoRegisterCertificate = value; + return @this; + } + + /// + /// Instructs a instance to automatically bind the X.509 certificate + /// to the port used for HTTPS. + /// + /// The on which this method is called. + /// with its AutoRegisterCertificate property + /// set to . + /// is . + /// The configuration of is locked. + /// The underlying operating system is not Windows. + public static WebServerOptions WithAutoRegisterCertificate(this WebServerOptions @this) + { + @this.AutoRegisterCertificate = true; + return @this; + } + + /// + /// Instructs a instance to not bind the X.509 certificate automatically. + /// + /// The on which this method is called. + /// with its AutoRegisterCertificate property + /// set to . + /// is . + /// The configuration of is locked. + public static WebServerOptions WithoutAutoRegisterCertificate(this WebServerOptions @this) + { + @this.AutoRegisterCertificate = false; + return @this; + } + + /// + /// Sets a value indicating the X.509 certificate store where to load the certificate from. + /// + /// The on which this method is called. + /// One of the constants. + /// with its StoreName property + /// set to . + /// is . + /// The configuration of is locked. + /// + public static WebServerOptions WithStoreName(this WebServerOptions @this, StoreName value) + { + @this.StoreName = value; + return @this; + } + + /// + /// Sets a value indicating the location of the X.509 certificate store where to load the certificate from. + /// + /// The on which this method is called. + /// One of the constants. + /// with its StoreLocation property + /// set to . + /// is . + /// The configuration of is locked. + /// + public static WebServerOptions WithStoreLocation(this WebServerOptions @this, StoreLocation value) + { + @this.StoreLocation = value; + return @this; + } + + /// + /// Sets the name and location of the X.509 certificate store where to load the certificate from. + /// + /// The on which this method is called. + /// One of the constants. + /// One of the constants. + /// with its StoreName property + /// set to and its StoreLocation property + /// set to . + /// is . + /// The configuration of is locked. + /// + /// + public static WebServerOptions WithStore(this WebServerOptions @this, StoreName name, StoreLocation location) + { + @this.StoreName = name; + @this.StoreLocation = location; + return @this; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerState.cs b/src/EmbedIO/WebServerState.cs new file mode 100644 index 000000000..5f0548194 --- /dev/null +++ b/src/EmbedIO/WebServerState.cs @@ -0,0 +1,36 @@ +namespace EmbedIO +{ + // NOTE TO CONTRIBUTORS: + // ===================== + // Do not reorder fields or change their values. + // It is important that WebServerState values represent, + // in ascending order, the stages of a web server's lifetime, + // so that comparisons can be made; for example, + // State < WebServerState.Listening means "not yet ready to accept requests". + + /// + /// Represents the state of a web server. + /// + public enum WebServerState + { + /// + /// The web server has not been started yet. + /// + Created, + + /// + /// The web server has been started but it is still initializing. + /// + Loading, + + /// + /// The web server is ready to accept incoming requests. + /// + Listening, + + /// + /// The web server has been stopped. + /// + Stopped, + } +} diff --git a/src/Unosquare.Labs.EmbedIO/Core/WebServerStateChangedEventArgs.cs b/src/EmbedIO/WebServerStateChangedEventArgs.cs similarity index 61% rename from src/Unosquare.Labs.EmbedIO/Core/WebServerStateChangedEventArgs.cs rename to src/EmbedIO/WebServerStateChangedEventArgs.cs index 44145d2d5..45382b142 100644 --- a/src/Unosquare.Labs.EmbedIO/Core/WebServerStateChangedEventArgs.cs +++ b/src/EmbedIO/WebServerStateChangedEventArgs.cs @@ -1,12 +1,11 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using Constants; +using System; +namespace EmbedIO +{ /// /// Represents event arguments whenever the state of a web server changes. /// - public class WebServerStateChangedEventArgs: EventArgs + public class WebServerStateChangedEventArgs : EventArgs { /// /// Initializes a new instance of the class. @@ -29,11 +28,4 @@ public WebServerStateChangedEventArgs(WebServerState oldState, WebServerState ne /// public WebServerState OldState { get; } } - - /// - /// An event handler that is called whenever the state of a web server is changed. - /// - /// The sender. - /// The instance containing the event data. - public delegate void WebServerStateChangedEventHandler(object sender, WebServerStateChangedEventArgs e); -} +} \ No newline at end of file diff --git a/src/EmbedIO/WebServerStateChangedEventHandler.cs b/src/EmbedIO/WebServerStateChangedEventHandler.cs new file mode 100644 index 000000000..43b54297c --- /dev/null +++ b/src/EmbedIO/WebServerStateChangedEventHandler.cs @@ -0,0 +1,9 @@ +namespace EmbedIO +{ + /// + /// An event handler that is called whenever the state of a web server is changed. + /// + /// The sender. + /// The instance containing the event data. + public delegate void WebServerStateChangedEventHandler(object sender, WebServerStateChangedEventArgs e); +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/CloseStatusCode.cs b/src/EmbedIO/WebSockets/CloseStatusCode.cs similarity index 99% rename from src/Unosquare.Labs.EmbedIO/System.Net/CloseStatusCode.cs rename to src/EmbedIO/WebSockets/CloseStatusCode.cs index b0e41724d..e04e57902 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/CloseStatusCode.cs +++ b/src/EmbedIO/WebSockets/CloseStatusCode.cs @@ -1,4 +1,4 @@ -namespace Unosquare.Net +namespace EmbedIO.WebSockets { /// /// Indicates the status code for the WebSocket connection close. diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocket.cs b/src/EmbedIO/WebSockets/IWebSocket.cs similarity index 90% rename from src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocket.cs rename to src/EmbedIO/WebSockets/IWebSocket.cs index 8670a8a0a..3121bc6b1 100644 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocket.cs +++ b/src/EmbedIO/WebSockets/IWebSocket.cs @@ -1,15 +1,15 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Net; - using System; - using System.Threading; - using System.Threading.Tasks; +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +namespace EmbedIO.WebSockets +{ /// /// /// Interface to create a WebSocket implementation. /// - /// + /// public interface IWebSocket : IDisposable { /// diff --git a/src/EmbedIO/WebSockets/IWebSocketContext.cs b/src/EmbedIO/WebSockets/IWebSocketContext.cs new file mode 100644 index 000000000..2de842e61 --- /dev/null +++ b/src/EmbedIO/WebSockets/IWebSocketContext.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Security.Principal; +using System.Threading; +using EmbedIO.Sessions; + +namespace EmbedIO.WebSockets +{ + /// + /// Represents the context of a WebSocket connection. + /// + public interface IWebSocketContext + { + /// + /// Gets a unique identifier for a WebSocket context. + /// + string Id { get; } + + /// + /// Gets the used to cancel operations. + /// + CancellationToken CancellationToken { get; } + + /// + /// Gets the unique identifier of the opening handshake HTTP context. + /// + string HttpContextId { get; } + + /// + /// Gets the session proxy associated with the opening handshake HTTP context. + /// + ISessionProxy Session { get; } + + /// + /// Gets the dictionary of data associated with the opening handshake HTTP context. + /// + IDictionary Items { get; } + + /// + /// Gets the server IP address and port number to which the opening handshake request is directed. + /// + IPEndPoint LocalEndPoint { get; } + + /// + /// Gets the client IP address and port number from which the opening handshake request originated. + /// + IPEndPoint RemoteEndPoint { get; } + + /// The URI requested by the WebSocket client. + Uri RequestUri { get; } + + /// The HTTP headers that were sent to the server during the opening handshake. + NameValueCollection Headers { get; } + + /// The value of the Origin HTTP header included in the opening handshake. + string Origin { get; } + + /// The value of the SecWebSocketKey HTTP header included in the opening handshake. + string WebSocketVersion { get; } + + /// The list of subprotocols requested by the WebSocket client. + IEnumerable RequestedProtocols { get; } + + /// The accepted subprotocol. + string AcceptedProtocol { get; } + + /// The cookies that were passed to the server during the opening handshake. + ICookieCollection Cookies { get; } + + /// An object used to obtain identity, authentication information, and security roles for the WebSocket client. + IPrincipal User { get; } + + /// Whether the WebSocket client is authenticated. + bool IsAuthenticated { get; } + + /// Whether the WebSocket client connected from the local machine. + bool IsLocal { get; } + + /// Whether the WebSocket connection is secured using Secure Sockets Layer (SSL). + bool IsSecureConnection { get; } + + /// The interface used to interact with the WebSocket connection. + IWebSocket WebSocket { get; } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketReceiveResult.cs b/src/EmbedIO/WebSockets/IWebSocketReceiveResult.cs similarity index 95% rename from src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketReceiveResult.cs rename to src/EmbedIO/WebSockets/IWebSocketReceiveResult.cs index 431c82787..af0cf3143 100644 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketReceiveResult.cs +++ b/src/EmbedIO/WebSockets/IWebSocketReceiveResult.cs @@ -1,4 +1,4 @@ -namespace Unosquare.Labs.EmbedIO +namespace EmbedIO.WebSockets { /// /// Interface for WebSocket Receive Result object. diff --git a/src/EmbedIO/WebSockets/Internal/Fin.cs b/src/EmbedIO/WebSockets/Internal/Fin.cs new file mode 100644 index 000000000..920d0f26f --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/Fin.cs @@ -0,0 +1,22 @@ +namespace EmbedIO.WebSockets.Internal +{ + /// + /// Indicates whether a WebSocket frame is the final frame of a message. + /// + /// + /// The values of this enumeration are defined in + /// Section 5.2 of RFC 6455. + /// + internal enum Fin : byte + { + /// + /// Equivalent to numeric value 0. Indicates more frames of a message follow. + /// + More = 0x0, + + /// + /// Equivalent to numeric value 1. Indicates the final frame of a message. + /// + Final = 0x1, + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/FragmentBuffer.cs b/src/EmbedIO/WebSockets/Internal/FragmentBuffer.cs similarity index 72% rename from src/Unosquare.Labs.EmbedIO/System.Net/FragmentBuffer.cs rename to src/EmbedIO/WebSockets/Internal/FragmentBuffer.cs index 834ad8a98..574b04721 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/FragmentBuffer.cs +++ b/src/EmbedIO/WebSockets/Internal/FragmentBuffer.cs @@ -1,10 +1,9 @@ -namespace Unosquare.Net -{ - using System.IO; - using Labs.EmbedIO; - using System.Threading.Tasks; - using Labs.EmbedIO.Constants; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +namespace EmbedIO.WebSockets.Internal +{ internal class FragmentBuffer : MemoryStream { private readonly bool _fragmentsCompressed; @@ -21,7 +20,7 @@ public FragmentBuffer(Opcode frameOpcode, bool frameIsCompressed) public async Task GetMessage(CompressionMethod compression) { var data = _fragmentsCompressed - ? await this.CompressAsync(compression, System.IO.Compression.CompressionMode.Decompress).ConfigureAwait(false) + ? await this.CompressAsync(compression, false, CancellationToken.None).ConfigureAwait(false) : this; return new MessageEventArgs(_fragmentsOpcode, data.ToArray()); diff --git a/src/EmbedIO/WebSockets/Internal/Mask.cs b/src/EmbedIO/WebSockets/Internal/Mask.cs new file mode 100644 index 000000000..c6e4b1199 --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/Mask.cs @@ -0,0 +1,22 @@ +namespace EmbedIO.WebSockets.Internal +{ + /// + /// Indicates whether the payload data of a WebSocket frame is masked. + /// + /// + /// The values of this enumeration are defined in + /// Section 5.2 of RFC 6455. + /// + internal enum Mask : byte + { + /// + /// Equivalent to numeric value 0. Indicates not masked. + /// + Off = 0x0, + + /// + /// Equivalent to numeric value 1. Indicates masked. + /// + On = 0x1, + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebSockets/Internal/MessageEventArgs.cs b/src/EmbedIO/WebSockets/Internal/MessageEventArgs.cs new file mode 100644 index 000000000..230563280 --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/MessageEventArgs.cs @@ -0,0 +1,114 @@ +using System; +using Swan; + +namespace EmbedIO.WebSockets.Internal +{ + /// + /// Represents the event data for the event. + /// + /// + /// + /// That event occurs when the receives + /// a message or a ping if the + /// property is set to true. + /// + /// + /// If you would like to get the message data, you should access + /// the or property. + /// + /// + internal class MessageEventArgs : EventArgs + { + private readonly byte[] _rawData; + private string _data; + private bool _dataSet; + + internal MessageEventArgs(WebSocketFrame frame) + { + Opcode = frame.Opcode; + _rawData = frame.PayloadData.ApplicationData.ToArray(); + } + + internal MessageEventArgs(Opcode opcode, byte[] rawData) + { + if ((ulong)rawData.Length > PayloadData.MaxLength) + throw new WebSocketException(CloseStatusCode.TooBig); + + Opcode = opcode; + _rawData = rawData; + } + + /// + /// Gets the message data as a . + /// + /// + /// A that represents the message data if its type is + /// text or ping and if decoding it to a string has successfully done; + /// otherwise, . + /// + public string Data + { + get + { + SetData(); + return _data; + } + } + + /// + /// Gets a value indicating whether the message type is binary. + /// + /// + /// true if the message type is binary; otherwise, false. + /// + public bool IsBinary => Opcode == Opcode.Binary; + + /// + /// Gets a value indicating whether the message type is ping. + /// + /// + /// true if the message type is ping; otherwise, false. + /// + public bool IsPing => Opcode == Opcode.Ping; + + /// + /// Gets a value indicating whether the message type is text. + /// + /// + /// true if the message type is text; otherwise, false. + /// + public bool IsText => Opcode == Opcode.Text; + + /// + /// Gets the message data as an array of . + /// + /// + /// An array of that represents the message data. + /// + public byte[] RawData + { + get + { + SetData(); + return _rawData; + } + } + + internal Opcode Opcode { get; } + + private void SetData() + { + if (_dataSet) + return; + + if (Opcode == Opcode.Binary) + { + _dataSet = true; + return; + } + + _data = _rawData.ToText(); + _dataSet = true; + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/PayloadData.cs b/src/EmbedIO/WebSockets/Internal/PayloadData.cs similarity index 83% rename from src/Unosquare.Labs.EmbedIO/System.Net/PayloadData.cs rename to src/EmbedIO/WebSockets/Internal/PayloadData.cs index 79ea6a713..f910f05e8 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/PayloadData.cs +++ b/src/EmbedIO/WebSockets/Internal/PayloadData.cs @@ -1,14 +1,16 @@ -namespace Unosquare.Net -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Text; - using Swan; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Swan; +using EmbedIO.Net.Internal; +namespace EmbedIO.WebSockets.Internal +{ internal class PayloadData { - public static readonly ulong MaxLength = long.MaxValue; + public const ulong MaxLength = long.MaxValue; private readonly byte[] _data; private ushort? _code; @@ -35,7 +37,7 @@ internal ushort Code if (!_code.HasValue) { _code = _data.Length > 1 - ? BitConverter.ToUInt16(_data.SubArray(0, 2).ToHostOrder(Endianness.Big), 0) + ? BitConverter.ToUInt16(_data.Take(2).ToArray().ToHostOrder(Endianness.Big), 0) : (ushort)1005; } diff --git a/src/EmbedIO/WebSockets/Internal/Rsv.cs b/src/EmbedIO/WebSockets/Internal/Rsv.cs new file mode 100644 index 000000000..f953c6031 --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/Rsv.cs @@ -0,0 +1,22 @@ +namespace EmbedIO.WebSockets.Internal +{ + /// + /// Indicates whether each RSV (RSV1, RSV2, and RSV3) of a WebSocket frame is non-zero. + /// + /// + /// The values of this enumeration are defined in + /// Section 5.2 of RFC 6455. + /// + internal enum Rsv : byte + { + /// + /// Equivalent to numeric value 0. Indicates zero. + /// + Off = 0x0, + + /// + /// Equivalent to numeric value 1. Indicates non-zero. + /// + On = 0x1, + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebSockets/Internal/StreamExtensions.cs b/src/EmbedIO/WebSockets/Internal/StreamExtensions.cs new file mode 100644 index 000000000..5cc33618c --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/StreamExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace EmbedIO.WebSockets.Internal +{ + internal static class StreamExtensions + { + private static readonly byte[] LastByte = { 0x00 }; + + // Compresses or decompresses a stream using the specified compression method. + public static async Task CompressAsync( + this Stream @this, + CompressionMethod method, + bool compress, + CancellationToken cancellationToken) + { + @this.Position = 0; + var targetStream = new MemoryStream(); + + switch (method) + { + case CompressionMethod.Deflate: + if (compress) + { + using (var compressor = new DeflateStream(targetStream, CompressionMode.Compress, true)) + { + await @this.CopyToAsync(compressor, 1024, cancellationToken).ConfigureAwait(false); + await @this.CopyToAsync(compressor).ConfigureAwait(false); + + // WebSocket use this + targetStream.Write(LastByte, 0, 1); + targetStream.Position = 0; + } + } + else + { + using (var compressor = new DeflateStream(@this, CompressionMode.Decompress)) + { + await compressor.CopyToAsync(targetStream).ConfigureAwait(false); + } + } + + break; + case CompressionMethod.Gzip: + if (compress) + { + using (var compressor = new GZipStream(targetStream, CompressionMode.Compress, true)) + { + await @this.CopyToAsync(compressor).ConfigureAwait(false); + } + } + else + { + using (var compressor = new GZipStream(@this, CompressionMode.Decompress)) + { + await compressor.CopyToAsync(targetStream).ConfigureAwait(false); + } + } + + break; + case CompressionMethod.None: + await @this.CopyToAsync(targetStream).ConfigureAwait(false); + break; + default: + throw new ArgumentOutOfRangeException(nameof(method), method, null); + } + + return targetStream; + } + } +} \ No newline at end of file diff --git a/src/EmbedIO/WebSockets/Internal/SystemWebSocket.cs b/src/EmbedIO/WebSockets/Internal/SystemWebSocket.cs new file mode 100644 index 000000000..ec9d93b78 --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/SystemWebSocket.cs @@ -0,0 +1,78 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace EmbedIO.WebSockets.Internal +{ + internal sealed class SystemWebSocket : IWebSocket + { + public SystemWebSocket(System.Net.WebSockets.WebSocket webSocket) + { + UnderlyingWebSocket = webSocket; + } + + ~SystemWebSocket() + { + Dispose(false); + } + + public System.Net.WebSockets.WebSocket UnderlyingWebSocket { get; } + + public WebSocketState State => UnderlyingWebSocket.State; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public Task SendAsync(byte[] buffer, bool isText, CancellationToken cancellationToken = default) + => UnderlyingWebSocket.SendAsync( + new ArraySegment(buffer), + isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary, + true, + cancellationToken); + + /// + public Task CloseAsync(CancellationToken cancellationToken = default) => + UnderlyingWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken); + + /// + public Task CloseAsync(CloseStatusCode code, string comment = null, CancellationToken cancellationToken = default)=> + UnderlyingWebSocket.CloseAsync(MapCloseStatus(code), comment ?? string.Empty, cancellationToken); + + private void Dispose(bool disposing) + { + if (!disposing) + return; + + UnderlyingWebSocket.Dispose(); + } + + private WebSocketCloseStatus MapCloseStatus(CloseStatusCode code) + { + switch (code) + { + case CloseStatusCode.Normal: + return WebSocketCloseStatus.NormalClosure; + case CloseStatusCode.ProtocolError: + return WebSocketCloseStatus.ProtocolError; + case CloseStatusCode.InvalidData: + case CloseStatusCode.UnsupportedData: + return WebSocketCloseStatus.InvalidPayloadData; + case CloseStatusCode.PolicyViolation: + return WebSocketCloseStatus.PolicyViolation; + case CloseStatusCode.TooBig: + return WebSocketCloseStatus.MessageTooBig; + case CloseStatusCode.MandatoryExtension: + return WebSocketCloseStatus.MandatoryExtension; + case CloseStatusCode.ServerError: + return WebSocketCloseStatus.InternalServerError; + default: + throw new ArgumentOutOfRangeException(nameof(code), code, null); + } + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/WebSocketReceiveResult.cs b/src/EmbedIO/WebSockets/Internal/SystemWebSocketReceiveResult.cs similarity index 66% rename from src/Unosquare.Labs.EmbedIO/WebSocketReceiveResult.cs rename to src/EmbedIO/WebSockets/Internal/SystemWebSocketReceiveResult.cs index c898dc0a4..98ce67f18 100644 --- a/src/Unosquare.Labs.EmbedIO/WebSocketReceiveResult.cs +++ b/src/EmbedIO/WebSockets/Internal/SystemWebSocketReceiveResult.cs @@ -1,18 +1,18 @@ -namespace Unosquare.Labs.EmbedIO +namespace EmbedIO.WebSockets.Internal { /// /// Represents a wrapper around a regular WebSocketContext. /// /// - public class WebSocketReceiveResult : IWebSocketReceiveResult + internal sealed class SystemWebSocketReceiveResult : IWebSocketReceiveResult { private readonly System.Net.WebSockets.WebSocketReceiveResult _results; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The results. - public WebSocketReceiveResult(System.Net.WebSockets.WebSocketReceiveResult results) + public SystemWebSocketReceiveResult(System.Net.WebSockets.WebSocketReceiveResult results) { _results = results; } diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocket.cs b/src/EmbedIO/WebSockets/Internal/WebSocket.cs similarity index 57% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocket.cs rename to src/EmbedIO/WebSockets/Internal/WebSocket.cs index fb80a9ae4..0aa005669 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocket.cs +++ b/src/EmbedIO/WebSockets/Internal/WebSocket.cs @@ -1,17 +1,17 @@ -namespace Unosquare.Net +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Net.Internal; +using Swan; +using Swan.Logging; + +namespace EmbedIO.WebSockets.Internal { - using System; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.IO; - using System.Net; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using Labs.EmbedIO; - using Labs.EmbedIO.Constants; - using Swan; - /// /// Implements the WebSocket interface. /// @@ -19,150 +19,50 @@ /// The WebSocket class provides a set of methods and properties for two-way communication using /// the WebSocket protocol (RFC 6455). /// - internal class WebSocket : IWebSocket + internal sealed class WebSocket : IWebSocket { - private readonly object _forState = new object(); + public const string SupportedVersion = "13"; + + private readonly object _stateSyncRoot = new object(); private readonly ConcurrentQueue _messageEventQueue = new ConcurrentQueue(); - private readonly WebSocketValidator _validator; + private readonly Action _closeConnection; + private readonly TimeSpan _waitTime = TimeSpan.FromSeconds(1); - private CompressionMethod _compression = CompressionMethod.None; - private volatile WebSocketState _readyState = WebSocketState.Connecting; - private WebSocketContext _context; - private bool _enableRedirection; + private volatile WebSocketState _readyState; private AutoResetEvent _exitReceiving; - private string _extensions; private FragmentBuffer _fragmentsBuffer; private volatile bool _inMessage; - private string _origin; private AutoResetEvent _receivePong; private Stream _stream; - private TimeSpan _waitTime; - // As server - internal WebSocket(WebSocketContext context) + private WebSocket(HttpConnection connection) { - _context = context; - - WebSocketKey = new WebSocketKey(); - - IsSecure = context.IsSecureConnection; - _stream = context.Stream; - _waitTime = TimeSpan.FromSeconds(1); - _validator = new WebSocketValidator(this); + _closeConnection = connection.ForceClose; + _stream = connection.Stream; + _readyState = WebSocketState.Open; } - internal event EventHandler OnMessage; - - /// - /// Gets or sets the compression method used to compress a message on the WebSocket connection. - /// - /// - /// One of the enum values, specifies the compression method - /// used to compress a message. The default value is . - /// - public CompressionMethod Compression + ~WebSocket() { - get => _compression; - - set - { - lock (_forState) - { - if (!_validator.CheckIfAvailable(false)) - return; - - _compression = value; - } - } + Dispose(false); } - - /// - /// Gets or sets a value indicating whether the emits - /// a event when receives a ping. - /// - /// - /// true if the emits a event - /// when receives a ping; otherwise, false. The default value is false. - /// - public bool EmitOnPing { get; set; } - - /// - /// Gets a value indicating whether the WebSocket connection is secure. - /// - /// - /// true if the connection is secure; otherwise, false. - /// - public bool IsSecure { get; } /// - /// Gets or sets the value of the HTTP Origin header to send with - /// the WebSocket handshake request to the server. + /// Occurs when the receives a message. /// - /// - /// The sends the Origin header if this property has any. - /// - /// - /// - /// A that represents the value of - /// the Origin header to send. - /// The default value is . - /// - /// - /// The Origin header has the following syntax: - /// <scheme>://<host>[:<port>]. - /// - /// - public string Origin - { - get => _origin; - - set - { - lock (_forState) - { - if (!_validator.CheckIfAvailable(false)) - return; - - if (string.IsNullOrEmpty(value)) - { - _origin = value; - return; - } - - if (!Uri.TryCreate(value, UriKind.Absolute, out var origin) || origin.Segments.Length > 1) - { - "The syntax of an origin must be '://[:]'.".Error(nameof(Origin)); - - return; - } - - _origin = value.TrimEnd('/'); - } - } - } + public event EventHandler OnMessage; /// public WebSocketState State => _readyState; - /// - /// Gets the WebSocket URL used to connect, or accepted. - /// - /// - /// A that represents the URL used to connect, or accepted. - /// - public Uri Url => _context.RequestUri; - - internal bool InContinuation { get; private set; } + internal CompressionMethod Compression { get; } = CompressionMethod.None; - internal CookieCollection CookieCollection { get; } = new CookieCollection(); + internal bool EmitOnPing { get; set; } - // As server - internal bool IgnoreExtensions { get; set; } = true; - - internal WebSocketKey WebSocketKey { get; } + internal bool InContinuation { get; private set; } /// - public Task SendAsync(byte[] buffer, bool isText, CancellationToken ct) => SendAsync(buffer, isText ? Opcode.Text : Opcode.Binary, ct); + public Task SendAsync(byte[] buffer, bool isText, CancellationToken cancellationToken) => SendAsync(buffer, isText ? Opcode.Text : Opcode.Binary, cancellationToken); /// public Task CloseAsync(CancellationToken cancellationToken = default) => CloseAsync(CloseStatusCode.Normal, cancellationToken: cancellationToken); @@ -173,17 +73,37 @@ public Task CloseAsync( string reason = null, CancellationToken cancellationToken = default) { - if (!_validator.CheckIfAvailable()) - return Task.Delay(0, cancellationToken); - - if (code != CloseStatusCode.Undefined && - !WebSocketValidator.CheckParametersForClose(code, reason)) + bool CheckParametersForClose() { - return Task.Delay(0, cancellationToken); + if (code == CloseStatusCode.NoStatus && !string.IsNullOrEmpty(reason)) + { + "'code' cannot have a reason.".Trace(nameof(WebSocket)); + return false; + } + + if (code == CloseStatusCode.MandatoryExtension) + { + "'code' cannot be used by a server.".Trace(nameof(WebSocket)); + return false; + } + + if (!string.IsNullOrEmpty(reason) && Encoding.UTF8.GetBytes(reason).Length > 123) + { + "The size of 'reason' is greater than the allowable max size.".Trace(nameof(WebSocket)); + return false; + } + + return true; } + if (_readyState != WebSocketState.Open) + return Task.CompletedTask; + + if (code != CloseStatusCode.Undefined && !CheckParametersForClose()) + return Task.CompletedTask; + if (code == CloseStatusCode.NoStatus) - return InternalCloseAsync(ct: cancellationToken); + return InternalCloseAsync(cancellationToken: cancellationToken); var send = !IsOpcodeReserved(code); return InternalCloseAsync(new PayloadData((ushort)code, reason), send, send, cancellationToken); @@ -228,68 +148,73 @@ public Task PingAsync(string message) /// /// An array of that represents the binary data to send. /// The opcode. - /// The cancellation token. + /// The cancellation token. /// /// A task that represents the asynchronous of send /// binary data using websocket. /// - public async Task SendAsync(byte[] data, Opcode opcode, CancellationToken ct = default) + public async Task SendAsync(byte[] data, Opcode opcode, CancellationToken cancellationToken = default) { if (_readyState != WebSocketState.Open) throw new WebSocketException(CloseStatusCode.Normal, $"This operation isn\'t available in: {_readyState.ToString()}"); - WebSocketStream stream = null; - - try + using (var stream = new WebSocketStream(data, opcode, Compression)) { - stream = new WebSocketStream(data, opcode, _compression); - foreach (var frame in stream.GetFrames()) await Send(frame).ConfigureAwait(false); } - finally - { - stream?.Dispose(); - } } /// - void IDisposable.Dispose() + public void Dispose() { - try - { - InternalCloseAsync(new PayloadData((ushort)CloseStatusCode.Away)).Wait(); - } - catch - { - // Ignored - } + Dispose(true); + GC.SuppressFinalize(this); } - internal async Task InternalAcceptAsync() + internal static async Task AcceptAsync(HttpListenerContext httpContext, string acceptedProtocol) { - try + string CreateResponseKey(string clientKey) { - _validator.ThrowIfInvalid(_context); + const string Guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - WebSocketKey.KeyValue = _context.Headers[HttpHeaderNames.SecWebSocketKey]; + var buff = new StringBuilder(clientKey, 64).Append(Guid); +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms + using (var sha1 = SHA1.Create()) + { + return Convert.ToBase64String(sha1.ComputeHash(Encoding.UTF8.GetBytes(buff.ToString()))); + } +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms + } - if (!IgnoreExtensions) - ProcessSecWebSocketExtensionsClientHeader(_context.Headers[HttpHeaderNames.SecWebSocketExtensions]); + var requestHeaders = httpContext.Request.Headers; - await SendHandshakeAsync().ConfigureAwait(false); + var webSocketKey = requestHeaders[HttpHeaderNames.SecWebSocketKey]; - _readyState = WebSocketState.Open; - } - catch (Exception ex) - { - ex.Log(nameof(WebSocket)); - Fatal("An exception has occurred while accepting.", ex); + if (string.IsNullOrEmpty(webSocketKey)) + throw new WebSocketException(CloseStatusCode.ProtocolError, $"Includes no {HttpHeaderNames.SecWebSocketKey} header, or it has an invalid value."); - return; - } + var webSocketVersion = requestHeaders[HttpHeaderNames.SecWebSocketVersion]; + + if (webSocketVersion == null || webSocketVersion != SupportedVersion) + throw new WebSocketException(CloseStatusCode.ProtocolError, $"Includes no {HttpHeaderNames.SecWebSocketVersion} header, or it has an invalid value."); + + var ret = HttpResponse.CreateWebSocketResponse(); + + ret.Headers[HttpHeaderNames.SecWebSocketAccept] = CreateResponseKey(webSocketKey); + + if (acceptedProtocol != null) + ret.Headers[HttpHeaderNames.SecWebSocketProtocol] = acceptedProtocol; + + ret.SetCookies(httpContext.Request.Cookies); - Open(); + var bytes = Encoding.UTF8.GetBytes(ret.ToString()); + + await httpContext.Connection.Stream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + + var socket = new WebSocket(httpContext.Connection); + socket.Open(); + return socket; } internal async Task PingAsync(byte[] frameAsBytes, TimeSpan timeout) @@ -302,20 +227,33 @@ internal async Task PingAsync(byte[] frameAsBytes, TimeSpan timeout) return _receivePong != null && _receivePong.WaitOne(timeout); } - private static bool IsOpcodeReserved(CloseStatusCode code) => code == CloseStatusCode.Undefined || - code == CloseStatusCode.NoStatus || - code == CloseStatusCode.Abnormal || - code == CloseStatusCode.TlsHandshakeFailure; + private static bool IsOpcodeReserved(CloseStatusCode code) + => code == CloseStatusCode.Undefined + || code == CloseStatusCode.NoStatus + || code == CloseStatusCode.Abnormal + || code == CloseStatusCode.TlsHandshakeFailure; + + private void Dispose(bool disposing) + { + try + { + InternalCloseAsync(new PayloadData((ushort)CloseStatusCode.Away)).Await(); + } + catch + { + // Ignored + } + } private async Task InternalCloseAsync( PayloadData payloadData = null, bool send = true, bool receive = true, - CancellationToken ct = default) + CancellationToken cancellationToken = default) { - lock (_forState) + lock (_stateSyncRoot) { - if (_readyState == WebSocketState.Closing) + if (_readyState == WebSocketState.CloseReceived || _readyState == WebSocketState.CloseSent) { "The closing is already in progress.".Trace(nameof(InternalCloseAsync)); return; @@ -330,43 +268,44 @@ private async Task InternalCloseAsync( send = send && _readyState == WebSocketState.Open; receive = receive && send; - _readyState = WebSocketState.Closing; + _readyState = WebSocketState.CloseSent; } "Begin closing the connection.".Trace(nameof(InternalCloseAsync)); var bytes = send ? WebSocketFrame.CreateCloseFrame(payloadData).ToArray() : null; - await CloseHandshakeAsync(bytes, receive, ct).ConfigureAwait(false); + await CloseHandshakeAsync(bytes, receive, cancellationToken).ConfigureAwait(false); ReleaseResources(); "End closing the connection.".Trace(nameof(InternalCloseAsync)); - lock (_forState) + lock (_stateSyncRoot) { _readyState = WebSocketState.Closed; } } - private async Task CloseHandshakeAsync(byte[] frameAsBytes, - bool receive, - CancellationToken ct) + private async Task CloseHandshakeAsync( + byte[] frameAsBytes, + bool receive, + CancellationToken cancellationToken) { var sent = frameAsBytes != null; if (sent) { - await _stream.WriteAsync(frameAsBytes, 0, frameAsBytes.Length, ct).ConfigureAwait(false); + await _stream.WriteAsync(frameAsBytes, 0, frameAsBytes.Length, cancellationToken).ConfigureAwait(false); } if (receive && sent) _exitReceiving?.WaitOne(_waitTime); } - private void Fatal(string message, Exception exception = null) => Fatal(message, - (exception as WebSocketException)?.Code ?? CloseStatusCode.Abnormal); + private void Fatal(string message, Exception exception = null) + => Fatal(message, (exception as WebSocketException)?.Code ?? CloseStatusCode.Abnormal); - private void Fatal(string message, CloseStatusCode code) => - InternalCloseAsync(new PayloadData((ushort)code, message), !IsOpcodeReserved(code), false).Wait(); + private void Fatal(string message, CloseStatusCode code) + => InternalCloseAsync(new PayloadData((ushort)code, message), !IsOpcodeReserved(code), false).Await(); private void Message() { @@ -419,7 +358,7 @@ private async Task ProcessDataFrame(WebSocketFrame frame) { if (frame.IsCompressed) { - var ms = await frame.PayloadData.ApplicationData.CompressAsync(_compression, System.IO.Compression.CompressionMode.Decompress).ConfigureAwait(false); + var ms = await frame.PayloadData.ApplicationData.CompressAsync(Compression, false, CancellationToken.None).ConfigureAwait(false); _messageEventQueue.Enqueue(new MessageEventArgs(frame.Opcode, ms.ToArray())); } @@ -447,7 +386,7 @@ private async Task ProcessFragmentFrame(WebSocketFrame frame) { using (_fragmentsBuffer) { - _messageEventQueue.Enqueue(await _fragmentsBuffer.GetMessage(_compression).ConfigureAwait(false)); + _messageEventQueue.Enqueue(await _fragmentsBuffer.GetMessage(Compression).ConfigureAwait(false)); } _fragmentsBuffer = null; @@ -493,8 +432,7 @@ private async Task ProcessReceivedFrame(WebSocketFrame frame) await ProcessCloseFrame(frame).ConfigureAwait(false); break; default: - $"An unsupported frame: {frame.PrintToString()}".Error(nameof(ProcessReceivedFrame)); - Fatal("There is no way to handle it.", CloseStatusCode.PolicyViolation); + Fatal($"Unsupported frame received: {frame.PrintToString()}", CloseStatusCode.PolicyViolation); return false; } } @@ -502,43 +440,10 @@ private async Task ProcessReceivedFrame(WebSocketFrame frame) return true; } - // As server - private void ProcessSecWebSocketExtensionsClientHeader(string value) - { - if (value == null) - return; - - var buff = new StringBuilder(80); - - var comp = false; - foreach (var e in value.SplitHeaderValue(Strings.CommaSplitChar)) - { - var ext = e.Trim(); - - if (comp || !ext.StartsWith(CompressionMethod.Deflate.ToExtensionString())) continue; - - _compression = CompressionMethod.Deflate; - buff.AppendFormat( - "{0}, ", - _compression.ToExtensionString( - "client_no_context_takeover", "server_no_context_takeover")); - - comp = true; - } - - var len = buff.Length; - if (len > 2) - { - buff.Length = len - 2; - _extensions = buff.ToString(); - } - } - private void ReleaseResources() { - _context.CloseAsync(); + _closeConnection(); _stream = null; - _context = null; if (_fragmentsBuffer != null) { @@ -558,10 +463,10 @@ private void ReleaseResources() _exitReceiving.Dispose(); _exitReceiving = null; } - + private Task Send(WebSocketFrame frame) { - lock (_forState) + lock (_stateSyncRoot) { if (_readyState != WebSocketState.Open) { @@ -574,24 +479,6 @@ private Task Send(WebSocketFrame frame) return _stream.WriteAsync(frameAsBytes, 0, frameAsBytes.Length); } - // As server - private Task SendHandshakeAsync() - { - var ret = HttpResponse.CreateWebSocketResponse(); - - var headers = ret.Headers; - headers[HttpHeaderNames.SecWebSocketAccept] = WebSocketKey.CreateResponseKey(); - - if (_extensions != null) - headers[HttpHeaderNames.SecWebSocketExtensions] = _extensions; - - ret.SetCookies(CookieCollection); - - var bytes = Encoding.UTF8.GetBytes(ret.ToString()); - - return _stream.WriteAsync(bytes, 0, bytes.Length); - } - private void StartReceiving() { while (_messageEventQueue.TryDequeue(out _)) @@ -624,7 +511,7 @@ private void StartReceiving() return; } - var _ = Task.Run(Message); + var dummy = Task.Run(Message); } catch (Exception ex) { @@ -634,4 +521,4 @@ private void StartReceiving() }); } } -} +} \ No newline at end of file diff --git a/src/EmbedIO/WebSockets/Internal/WebSocketContext.cs b/src/EmbedIO/WebSockets/Internal/WebSocketContext.cs new file mode 100644 index 000000000..d5484e3d9 --- /dev/null +++ b/src/EmbedIO/WebSockets/Internal/WebSocketContext.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Security.Principal; +using System.Threading; +using EmbedIO.Sessions; +using EmbedIO.Utilities; + +namespace EmbedIO.WebSockets.Internal +{ + internal sealed class WebSocketContext : IWebSocketContext + { + internal WebSocketContext( + IHttpContextImpl httpContext, + string webSocketVersion, + IEnumerable requestedProtocols, + string acceptedProtocol, + IWebSocket webSocket, + CancellationToken cancellationToken) + { + Id = UniqueIdGenerator.GetNext(); + CancellationToken = cancellationToken; + HttpContextId = httpContext.Id; + Session = httpContext.Session; + Items = httpContext.Items; + LocalEndPoint = httpContext.LocalEndPoint; + RemoteEndPoint = httpContext.RemoteEndPoint; + RequestUri = httpContext.Request.Url; + Headers = httpContext.Request.Headers; + Origin = Headers[HttpHeaderNames.Origin]; + RequestedProtocols = requestedProtocols; + AcceptedProtocol = acceptedProtocol; + WebSocketVersion = webSocketVersion; + Cookies = httpContext.Request.Cookies; + User = httpContext.User; + IsAuthenticated = httpContext.Request.IsAuthenticated; + IsLocal = httpContext.Request.IsLocal; + IsSecureConnection = httpContext.Request.IsSecureConnection; + WebSocket = webSocket; + } + + /// + public string Id { get; } + + /// + public CancellationToken CancellationToken { get; } + + /// + public string HttpContextId { get; } + + /// + public ISessionProxy Session { get; } + + /// + public IDictionary Items { get; } + + /// + public IPEndPoint LocalEndPoint { get; } + + /// + public IPEndPoint RemoteEndPoint { get; } + + /// + public Uri RequestUri { get; } + + /// + public NameValueCollection Headers { get; } + + /// + public string Origin { get; } + + /// + public IEnumerable RequestedProtocols { get; } + + /// + public string AcceptedProtocol { get; } + + /// + public string WebSocketVersion { get; } + + /// + public ICookieCollection Cookies { get; } + + /// + public IPrincipal User { get; } + + /// + public bool IsAuthenticated { get; } + + /// + public bool IsLocal { get; } + + /// + public bool IsSecureConnection { get; } + + /// + public IWebSocket WebSocket { get; } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrame.cs b/src/EmbedIO/WebSockets/Internal/WebSocketFrame.cs similarity index 75% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrame.cs rename to src/EmbedIO/WebSockets/Internal/WebSocketFrame.cs index 076a9397b..bb913b573 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrame.cs +++ b/src/EmbedIO/WebSockets/Internal/WebSocketFrame.cs @@ -1,80 +1,16 @@ -namespace Unosquare.Net +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using EmbedIO.Net.Internal; +using Swan; + +namespace EmbedIO.WebSockets.Internal { - using Labs.EmbedIO.Constants; - using Swan; - using System; - using System.Collections.Generic; - using System.IO; - - /// - /// Indicates whether a WebSocket frame is the final frame of a message. - /// - /// - /// The values of this enumeration are defined in - /// Section 5.2 of RFC 6455. - /// - internal enum Fin : byte - { - /// - /// Equivalent to numeric value 0. Indicates more frames of a message follow. - /// - More = 0x0, - - /// - /// Equivalent to numeric value 1. Indicates the final frame of a message. - /// - Final = 0x1, - } - - /// - /// Indicates whether the payload data of a WebSocket frame is masked. - /// - /// - /// The values of this enumeration are defined in - /// Section 5.2 of RFC 6455. - /// - internal enum Mask : byte - { - /// - /// Equivalent to numeric value 0. Indicates not masked. - /// - Off = 0x0, - - /// - /// Equivalent to numeric value 1. Indicates masked. - /// - On = 0x1, - } - - /// - /// Indicates whether each RSV (RSV1, RSV2, and RSV3) of a WebSocket frame is non-zero. - /// - /// - /// The values of this enumeration are defined in - /// Section 5.2 of RFC 6455. - /// - internal enum Rsv : byte - { - /// - /// Equivalent to numeric value 0. Indicates zero. - /// - Off = 0x0, - - /// - /// Equivalent to numeric value 1. Indicates non-zero. - /// - On = 0x1, - } - internal class WebSocketFrame { - internal static readonly byte[] EmptyPingBytes; - - static WebSocketFrame() - { - EmptyPingBytes = CreatePingFrame().ToArray(); - } - + internal static readonly byte[] EmptyPingBytes = CreatePingFrame().ToArray(); + internal WebSocketFrame(Opcode opcode, PayloadData payloadData) : this(Fin.Final, opcode, payloadData) { @@ -162,7 +98,7 @@ public string PrintToString() var payloadLen = PayloadLength; // Extended Payload Length - var extPayloadLen = payloadLen > 125 ? FullPayloadLength.ToString() : string.Empty; + var extPayloadLen = payloadLen > 125 ? FullPayloadLength.ToString(CultureInfo.InvariantCulture) : string.Empty; // Masking Key var maskingKey = BitConverter.ToString(MaskingKey); @@ -200,7 +136,7 @@ public byte[] ToArray() header = (header << 1) + (int)Rsv3; header = (header << 4) + (int)Opcode; header = (header << 1) + (int)Mask; - header = (header << 7) + (int)PayloadLength; + header = (header << 7) + PayloadLength; buff.Write(((ushort)header).ToByteArray(Endianness.Big), 0, 2); if (PayloadLength > 125) diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrameStream.cs b/src/EmbedIO/WebSockets/Internal/WebSocketFrameStream.cs similarity index 94% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrameStream.cs rename to src/EmbedIO/WebSockets/Internal/WebSocketFrameStream.cs index 98e898830..8c9a0bccd 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketFrameStream.cs +++ b/src/EmbedIO/WebSockets/Internal/WebSocketFrameStream.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; +using System; +using System.IO; +using System.Threading.Tasks; +using Swan; -namespace Unosquare.Net +namespace EmbedIO.WebSockets.Internal { - using System; - using System.IO; - using System.Threading.Tasks; - using Swan; - internal class WebSocketFrameStream { private readonly bool _unmask; @@ -42,9 +40,9 @@ internal async Task ReadFrameAsync(WebSocket webSocket) private static bool IsOpcodeControl(byte opcode) => opcode > 0x7 && opcode < 0x10; - private static WebSocketFrame ProcessHeader(IReadOnlyList header) + private static WebSocketFrame ProcessHeader(byte[] header) { - if (header.Count != 2) + if (header.Length != 2) throw new WebSocketException("The header of a frame cannot be read from the stream."); // FIN @@ -160,4 +158,4 @@ private async Task ReadPayloadDataAsync(WebSocketFrame frame) frame.PayloadData = new PayloadData(bytes); } } -} +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketReceiveResult.cs b/src/EmbedIO/WebSockets/Internal/WebSocketReceiveResult.cs similarity index 79% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocketReceiveResult.cs rename to src/EmbedIO/WebSockets/Internal/WebSocketReceiveResult.cs index 5daad8717..07d1b9438 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketReceiveResult.cs +++ b/src/EmbedIO/WebSockets/Internal/WebSocketReceiveResult.cs @@ -1,19 +1,16 @@ -namespace Unosquare.Net -{ - using System; - using Labs.EmbedIO; +using System; +namespace EmbedIO.WebSockets.Internal +{ /// /// Represents a WS Receive result. /// - internal class WebSocketReceiveResult : IWebSocketReceiveResult + internal sealed class WebSocketReceiveResult : IWebSocketReceiveResult { internal WebSocketReceiveResult(int count, Opcode code) { if (count < 0) - { throw new ArgumentOutOfRangeException(nameof(count)); - } Count = count; EndOfMessage = code == Opcode.Close; diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketStream.cs b/src/EmbedIO/WebSockets/Internal/WebSocketStream.cs similarity index 83% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocketStream.cs rename to src/EmbedIO/WebSockets/Internal/WebSocketStream.cs index 38c5d3f24..322ee9a5d 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketStream.cs +++ b/src/EmbedIO/WebSockets/Internal/WebSocketStream.cs @@ -1,14 +1,14 @@ -namespace Unosquare.Net -{ - using Labs.EmbedIO; - using Labs.EmbedIO.Constants; - using System.Collections.Generic; - using System.IO; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Swan; +namespace EmbedIO.WebSockets.Internal +{ internal class WebSocketStream : MemoryStream { - internal static readonly byte[] EmptyBytes = new byte[0]; - internal static readonly int FragmentLength = 1016; + internal const int FragmentLength = 1016; private readonly CompressionMethod _compression; private readonly Opcode _opcode; @@ -23,8 +23,8 @@ public WebSocketStream(byte[] data, Opcode opcode, CompressionMethod compression public IEnumerable GetFrames() { var compressed = _compression != CompressionMethod.None; - Stream stream = _compression != CompressionMethod.None - ? this.CompressAsync(_compression).GetAwaiter().GetResult() + var stream = compressed + ? this.CompressAsync(_compression, true, CancellationToken.None).Await() : this; var len = stream.Length; @@ -33,7 +33,7 @@ public IEnumerable GetFrames() if (len == 0) { - yield return new WebSocketFrame(Fin.Final, _opcode, EmptyBytes, compressed); + yield return new WebSocketFrame(Fin.Final, _opcode, Array.Empty(), compressed); yield break; } diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/Opcode.cs b/src/EmbedIO/WebSockets/Opcode.cs similarity index 97% rename from src/Unosquare.Labs.EmbedIO/System.Net/Opcode.cs rename to src/EmbedIO/WebSockets/Opcode.cs index 6ebce7930..195cf5363 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/Opcode.cs +++ b/src/EmbedIO/WebSockets/Opcode.cs @@ -1,4 +1,4 @@ -namespace Unosquare.Net +namespace EmbedIO.WebSockets { /// /// Indicates the WebSocket frame type. diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketException.cs b/src/EmbedIO/WebSockets/WebSocketException.cs similarity index 86% rename from src/Unosquare.Labs.EmbedIO/System.Net/WebSocketException.cs rename to src/EmbedIO/WebSockets/WebSocketException.cs index 055a638e0..eb3f5ccf1 100644 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketException.cs +++ b/src/EmbedIO/WebSockets/WebSocketException.cs @@ -1,11 +1,13 @@ -namespace Unosquare.Net -{ - using System; +using System; +namespace EmbedIO.WebSockets +{ /// - /// The exception that is thrown when a gets a fatal error. + /// The exception that is thrown when a gets a fatal error. /// - internal class WebSocketException : Exception +#pragma warning disable CA1032 // Implement standard exception constructors - this class doesn't need public constructors. + public class WebSocketException : Exception +#pragma warning restore CA1032 { internal WebSocketException(string message = null) : this(CloseStatusCode.Abnormal, message) diff --git a/src/EmbedIO/WebSockets/WebSocketModule.cs b/src/EmbedIO/WebSockets/WebSocketModule.cs new file mode 100644 index 000000000..33d2bde83 --- /dev/null +++ b/src/EmbedIO/WebSockets/WebSocketModule.cs @@ -0,0 +1,638 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Utilities; +using EmbedIO.WebSockets.Internal; +using Swan; +using Swan.Logging; +using Swan.Threading; + +namespace EmbedIO.WebSockets +{ + /// + /// A base class for modules that handle WebSocket connections. + /// + /// + /// Each WebSocket server has a list of WebSocket subprotocols it can accept. + /// When a client initiates a WebSocket opening handshake: + /// + /// if the list of accepted subprotocols is empty, + /// the connection is accepted only if no SecWebSocketProtocol + /// header is present in the request; + /// if the list of accepted subprotocols is not empty, + /// the connection is accepted only if one or more SecWebSocketProtocol + /// headers are present in the request and one of them specifies one + /// of the subprotocols in the list. The first subprotocol specified by the client + /// that is also present in the module's list is then specified in the + /// handshake response. + /// + /// If a connection is not accepted because of a subprotocol mismatch, + /// a 400 Bad Request response is sent back to the client. The response + /// contains one or more SecWebSocketProtocol headers that specify + /// the list of accepted subprotocols (if any). + /// + public abstract class WebSocketModule : WebModuleBase, IDisposable + { + private const int ReceiveBufferSize = 2048; + + private readonly bool _enableConnectionWatchdog; + private readonly List _protocols = new List(); + private readonly ConcurrentDictionary _contexts = new ConcurrentDictionary(); + private bool _isDisposing; + private int _maxMessageSize; + private TimeSpan _keepAliveInterval; + private Encoding _encoding; + private PeriodicTask _connectionWatchdog; + + /// + /// Initializes a new instance of the class. + /// + /// The URL path of the WebSocket endpoint to serve. + /// If set to , + /// contexts representing closed connections will automatically be purged + /// from every 30 seconds.. + protected WebSocketModule(string urlPath, bool enableConnectionWatchdog) + : base(urlPath) + { + _enableConnectionWatchdog = enableConnectionWatchdog; + _maxMessageSize = 0; + _keepAliveInterval = TimeSpan.FromSeconds(30); + _encoding = Encoding.UTF8; + } + + /// + public sealed override bool IsFinalHandler => true; + + /// + /// Gets or sets the maximum size of a received message. + /// If a message exceeding the maximum size is received from a client, + /// the connection is closed automatically. + /// The default value is 0, which disables message size checking. + /// + protected int MaxMessageSize + { + get => _maxMessageSize; + set + { + EnsureConfigurationNotLocked(); + _maxMessageSize = Math.Max(value, 0); + } + } + + /// + /// Gets or sets the keep-alive interval for the WebSocket connection. + /// The default is 30 seconds. + /// + /// This property is being set to a value + /// that is too small to be acceptable. + protected TimeSpan KeepAliveInterval + { + get => _keepAliveInterval; + set + { + EnsureConfigurationNotLocked(); + if (value != Timeout.InfiniteTimeSpan && value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(value), "The specified keep-alive interval is too small."); + + _keepAliveInterval = value; + } + } + + /// + /// Gets the used by the method + /// to send a string. The default is per the WebSocket specification. + /// + /// This property is being set to . + protected Encoding Encoding + { + get => _encoding; + set + { + EnsureConfigurationNotLocked(); + _encoding = Validate.NotNull(nameof(value), value); + } + } + + /// + /// Gets a list of interfaces + /// representing the currently connected clients. + /// + protected IReadOnlyList ActiveContexts + { + get + { + // ConcurrentDictionary.Values, although declared as ICollection, + // will probably return a ReadOnlyCollection, which implements IReadOnlyList: + // https://referencesource.microsoft.com/#mscorlib/system/Collections/Concurrent/ConcurrentDictionary.cs,fe55c11912af21d2 + // https://github.com/dotnet/corefx/blob/master/src/System.Collections.Concurrent/src/System/Collections/Concurrent/ConcurrentDictionary.cs#L1990 + // https://github.com/mono/mono/blob/master/mcs/class/referencesource/mscorlib/system/collections/Concurrent/ConcurrentDictionary.cs#L1961 + // However there is no formal guarantee, so be ready to convert to a list, just in case. + var values = _contexts.Values; + return values is IReadOnlyList list + ? list + : values.ToList(); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + protected sealed override async Task OnRequestAsync(IHttpContext context) + { + // The WebSocket endpoint must match exactly, giving a RequestedPath of "/". + // In all other cases the path is longer, so there's no need to compare strings here. + if (context.RequestedPath.Length > 1) + return; + + var requestedProtocols = context.Request.Headers.GetValues(HttpHeaderNames.SecWebSocketProtocol) + ?.Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToArray() + ?? Array.Empty(); + string acceptedProtocol; + bool acceptConnection; + if (_protocols.Count > 0) + { + acceptedProtocol = requestedProtocols.FirstOrDefault(p => _protocols.Contains(p)); + acceptConnection = acceptedProtocol != null; + } + else + { + acceptedProtocol = null; + acceptConnection = requestedProtocols.Length == 0; + } + + if (!acceptConnection) + { + $"{BaseRoute} - Rejecting WebSocket connection: no subprotocol was accepted.".Debug(nameof(WebSocketModule)); + foreach (var protocol in _protocols) + context.Response.Headers.Add(HttpHeaderNames.SecWebSocketProtocol, protocol); + + // Not throwing a HTTP exception here because a WebSocket client + // does not care about nice, formatted messages. + context.Response.SetEmptyResponse((int)HttpStatusCode.BadRequest); + return; + } + + if (!(context is IHttpContextImpl contextImpl)) + throw new InvalidOperationException($"HTTP context must implement {nameof(IHttpContextImpl)}."); + + $"{BaseRoute} - Accepting WebSocket connection with subprotocol {acceptedProtocol ?? ""}".Debug(nameof(WebSocketModule)); + var webSocketContext = await contextImpl.AcceptWebSocketAsync( + requestedProtocols, + acceptedProtocol, + ReceiveBufferSize, + KeepAliveInterval, + context.CancellationToken).ConfigureAwait(false); + + PurgeDisconnectedContexts(); + _contexts.TryAdd(webSocketContext.Id, webSocketContext); + + $"{BaseRoute} - WebSocket connection accepted - There are now {_contexts.Count} sockets connected." + .Debug(nameof(WebSocketModule)); + + await OnClientConnectedAsync(webSocketContext).ConfigureAwait(false); + + try + { + if (webSocketContext.WebSocket is SystemWebSocket systemWebSocket) + { + await ProcessSystemContext( + webSocketContext, + systemWebSocket.UnderlyingWebSocket, + context.CancellationToken).ConfigureAwait(false); + } + else + { + await ProcessEmbedIOContext(webSocketContext, context.CancellationToken) + .ConfigureAwait(false); + } + } + catch (TaskCanceledException) + { + // ignore + } + catch (Exception ex) + { + ex.Log(nameof(WebSocketModule)); + } + finally + { + // once the loop is completed or connection aborted, remove the WebSocket + RemoveWebSocket(webSocketContext); + } + } + + /// + protected override void OnStart(CancellationToken cancellationToken) + { + if (_enableConnectionWatchdog) + { + _connectionWatchdog = new PeriodicTask( + TimeSpan.FromSeconds(30), + ct => { + PurgeDisconnectedContexts(); + return Task.CompletedTask; + }, + cancellationToken); + } + } + + /// + /// Adds a WebSocket subprotocol to the list of protocols supported by a . + /// + /// The protocol name to add to the list. + /// is . + /// + /// contains one or more invalid characters, as defined + /// in RFC6455, Section 4.3. + /// - or - + /// is already in the list of supported protocols. + /// + /// The has already been started. + /// + /// + /// + protected void AddProtocol(string protocol) + { + protocol = Validate.Rfc2616Token(nameof(protocol), protocol); + + EnsureConfigurationNotLocked(); + + if (_protocols.Contains(protocol)) + throw new ArgumentException("Duplicate WebSocket protocol name.", nameof(protocol)); + + _protocols.Add(protocol); + } + + /// + /// Adds one or more WebSocket subprotocols to the list of protocols supported by a . + /// + /// The protocol names to add to the list. + /// + /// is . + /// - or - + /// One or more of the strings in is . + /// + /// + /// One or more of the strings in + /// contains one or more invalid characters, as defined + /// in RFC6455, Section 4.3. + /// - or - + /// One or more of the strings in + /// is already in the list of supported protocols. + /// + /// The has already been started. + /// + /// This method enumerates just once; hence, if an exception is thrown + /// because one of the specified protocols is or contains invalid characters, + /// any preceding protocol is added to the list of supported protocols. + /// + /// + /// + /// + protected void AddProtocols(IEnumerable protocols) + { + protocols = Validate.NotNull(nameof(protocols), protocols); + + EnsureConfigurationNotLocked(); + + foreach (var protocol in protocols.Select(p => Validate.Rfc2616Token(nameof(protocols), p))) + { + if (_protocols.Contains(protocol)) + throw new ArgumentException("Duplicate WebSocket protocol name.", nameof(protocols)); + + _protocols.Add(protocol); + } + } + + /// + /// Adds one or more WebSocket subprotocols to the list of protocols supported by a . + /// + /// The protocol names to add to the list. + /// + /// is . + /// - or - + /// One or more of the strings in is . + /// + /// + /// One or more of the strings in + /// contains one or more invalid characters, as defined + /// in RFC6455, Section 4.3. + /// - or - + /// One or more of the strings in + /// is already in the list of supported protocols. + /// + /// The has already been started. + /// + /// This method performs validation checks on all specified before adding them + /// to the list of supported protocols; hence, if an exception is thrown + /// because one of the specified protocols is or contains invalid characters, + /// none of the specified protocol names are added to the list. + /// + /// + /// + /// + protected void AddProtocols(params string[] protocols) + { + protocols = Validate.NotNull(nameof(protocols), protocols); + + if (protocols.Select(p => Validate.Rfc2616Token(nameof(protocols), p)).Any(protocol => _protocols.Contains(protocol))) + throw new ArgumentException("Duplicate WebSocket protocol name.", nameof(protocols)); + + EnsureConfigurationNotLocked(); + + _protocols.AddRange(protocols); + } + + /// + /// Sends a text payload. + /// + /// The web socket. + /// The payload. + /// A representing the ongoing operation. + protected async Task SendAsync(IWebSocketContext context, string payload) + { + try + { + var buffer = _encoding.GetBytes(payload ?? string.Empty); + + await context.WebSocket.SendAsync(buffer, true, context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + ex.Log(nameof(WebSocketModule)); + } + } + +#pragma warning disable CA1822 // Member can be declared as static - It is an instance method for API consistency. + /// + /// Sends a binary payload. + /// + /// The web socket. + /// The payload. + /// A representing the ongoing operation. + protected async Task SendAsync(IWebSocketContext context, byte[] payload) + { + try + { + await context.WebSocket.SendAsync(payload ?? Array.Empty(), false, context.CancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + ex.Log(nameof(WebSocketModule)); + } + } +#pragma warning restore CA1822 + + /// + /// Broadcasts the specified payload to all connected WebSocket clients. + /// + /// The payload. + /// A representing the ongoing operation. + protected Task BroadcastAsync(byte[] payload) + => Task.WhenAll(_contexts.Values.Select(c => SendAsync(c, payload))); + + /// + /// Broadcasts the specified payload to selected WebSocket clients. + /// + /// The payload. + /// A callback function that must return + /// for each context to be included in the broadcast. + /// A representing the ongoing operation. + protected Task BroadcastAsync(byte[] payload, Func selector) + => Task.WhenAll(_contexts.Values.Where(Validate.NotNull(nameof(selector), selector)).Select(c => SendAsync(c, payload))); + + /// + /// Broadcasts the specified payload to all connected WebSocket clients. + /// + /// The payload. + /// A representing the ongoing operation. + protected Task BroadcastAsync(string payload) + => Task.WhenAll(_contexts.Values.Select(c => SendAsync(c, payload))); + + /// + /// Broadcasts the specified payload to selected WebSocket clients. + /// + /// The payload. + /// A callback function that must return + /// for each context to be included in the broadcast. + /// A representing the ongoing operation. + protected Task BroadcastAsync(string payload, Func selector) + => Task.WhenAll(_contexts.Values.Where(Validate.NotNull(nameof(selector), selector)).Select(c => SendAsync(c, payload))); + + /// + /// Closes the specified web socket, removes it and disposes it. + /// + /// The web socket. + /// A representing the ongoing operation. + protected async Task CloseAsync(IWebSocketContext context) + { + if (context == null) + return; + + try + { + await context.WebSocket.CloseAsync(context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + ex.Log(nameof(WebSocketModule)); + } + finally + { + RemoveWebSocket(context); + } + } + + /// + /// Called when this WebSocket server receives a full message (EndOfMessage) from a client. + /// + /// The context. + /// The buffer. + /// The result. + /// A representing the ongoing operation. + protected abstract Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result); + + /// + /// Called when this WebSocket server receives a message frame regardless if the frame represents the EndOfMessage. + /// + /// The context. + /// The buffer. + /// The result. + /// A representing the ongoing operation. + protected virtual Task OnFrameReceivedAsync( + IWebSocketContext context, + byte[] buffer, + IWebSocketReceiveResult result) + => Task.CompletedTask; + + /// + /// Called when this WebSocket server accepts a new client. + /// + /// The context. + /// A representing the ongoing operation. + protected virtual Task OnClientConnectedAsync(IWebSocketContext context) => Task.CompletedTask; + + /// + /// Called when the server has removed a connected client for any reason. + /// + /// The context. + /// A representing the ongoing operation. + protected virtual Task OnClientDisconnectedAsync(IWebSocketContext context) => Task.CompletedTask; + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_isDisposing) + return; + + _isDisposing = true; + + if (disposing) + { + _connectionWatchdog?.Dispose(); + Task.WhenAll(_contexts.Values.Select(CloseAsync)).Await(false); + PurgeDisconnectedContexts(); + } + } + + private void RemoveWebSocket(IWebSocketContext context) + { + _contexts.TryRemove(context.Id, out _); + context.WebSocket?.Dispose(); + + // OnClientDisconnectedAsync is better called in its own task, + // so it may call methods that require a lock on _contextsAccess. + // Otherwise, calling e.g. Broadcast would result in a deadlock. +#pragma warning disable CS4014 // Call is not awaited - it is intentionally forked. + Task.Run(async () => { + try + { + await OnClientDisconnectedAsync(context).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + $"[{context.Id}] OnClientDisconnectedAsync was canceled.".Info(nameof(WebSocketModule)); + } + catch (Exception e) + { + e.Log(nameof(WebSocketModule), $"[{context.Id}] Exception in OnClientDisconnectedAsync."); + } + }); +#pragma warning restore CS4014 + } + + private void PurgeDisconnectedContexts() + { + var contexts = _contexts.Values; + var totalCount = _contexts.Count; + var purgedCount = 0; + foreach (var context in contexts) + { + if (context.WebSocket == null || context.WebSocket.State == WebSocketState.Open) + continue; + + RemoveWebSocket(context); + purgedCount++; + } + + $"{BaseRoute} - Purged {purgedCount} of {totalCount} sockets." + .Debug(nameof(WebSocketModule)); + } + + private async Task ProcessEmbedIOContext(IWebSocketContext context, CancellationToken cancellationToken) + { + ((Internal.WebSocket)context.WebSocket).OnMessage += async (s, e) => + { + if (e.Opcode == Opcode.Close) + { + await context.WebSocket.CloseAsync(context.CancellationToken).ConfigureAwait(false); + } + else + { + await OnMessageReceivedAsync( + context, + e.RawData, + new Internal.WebSocketReceiveResult(e.RawData.Length, e.Opcode)) + .ConfigureAwait(false); + } + }; + + while (context.WebSocket.State == WebSocketState.Open + || context.WebSocket.State == WebSocketState.CloseReceived + || context.WebSocket.State == WebSocketState.CloseSent) + { + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ProcessSystemContext(IWebSocketContext context, System.Net.WebSockets.WebSocket webSocket, CancellationToken cancellationToken) + { + // define a receive buffer + var receiveBuffer = new byte[ReceiveBufferSize]; + + // define a dynamic buffer that holds multi-part receptions + var receivedMessage = new List(receiveBuffer.Length * 2); + + // poll the WebSocket connections for reception + while (webSocket.State == WebSocketState.Open) + { + // retrieve the result (blocking) + var receiveResult = new SystemWebSocketReceiveResult( + await webSocket.ReceiveAsync(new ArraySegment(receiveBuffer), cancellationToken) + .ConfigureAwait(false)); + + if (receiveResult.MessageType == (int)WebSocketMessageType.Close) + { + // close the connection if requested by the client + await webSocket + .CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken) + .ConfigureAwait(false); + return; + } + + var frameBytes = new byte[receiveResult.Count]; + Array.Copy(receiveBuffer, frameBytes, frameBytes.Length); + await OnFrameReceivedAsync(context, frameBytes, receiveResult).ConfigureAwait(false); + + // add the response to the multi-part response + receivedMessage.AddRange(frameBytes); + + if (_maxMessageSize > 0 && receivedMessage.Count > _maxMessageSize) + { + // close the connection if message exceeds max length + await webSocket.CloseAsync( + WebSocketCloseStatus.MessageTooBig, + $"Message too big. Maximum is {_maxMessageSize} bytes.", + cancellationToken).ConfigureAwait(false); + + // exit the loop; we're done + return; + } + + // if we're at the end of the message, process the message + if (!receiveResult.EndOfMessage) continue; + + await OnMessageReceivedAsync(context, receivedMessage.ToArray(), receiveResult) + .ConfigureAwait(false); + receivedMessage.Clear(); + } + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/AppDbContext.cs b/src/Unosquare.Labs.EmbedIO.Samples/AppDbContext.cs deleted file mode 100644 index 13cbf0bbd..000000000 --- a/src/Unosquare.Labs.EmbedIO.Samples/AppDbContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using LiteLib; - - internal sealed class AppDbContext : LiteDbContext - { - public AppDbContext() : base("mydbfile.db", false) - { - // map this context to the database file mydbfile.db and don't use any logging capabilities. - } - - public LiteDbSet People { get; set; } - - public static void InitDatabase() - { - var dbContext = new AppDbContext(); - - foreach (var person in dbContext.People.SelectAll()) - dbContext.People.Delete(person); - - dbContext.People.Insert(new Person - { - Name = "Mario Di Vece", - Age = 31, - EmailAddress = "mario@unosquare.com" - }); - dbContext.People.Insert(new Person - { - Name = "Geovanni Perez", - Age = 32, - EmailAddress = "geovanni.perez@unosquare.com" - }); - dbContext.People.Insert(new Person - { - Name = "Luis Gonzalez", - Age = 29, - EmailAddress = "luis.gonzalez@unosquare.com" - }); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/PeopleController.cs b/src/Unosquare.Labs.EmbedIO.Samples/PeopleController.cs deleted file mode 100644 index 6c95d6681..000000000 --- a/src/Unosquare.Labs.EmbedIO.Samples/PeopleController.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using System; - using Constants; - using Modules; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using Tubular; - using Tubular.ObjectModel; - - /// - /// A very simple controller to handle People CRUD. - /// Notice how it Inherits from WebApiController and the methods have WebApiHandler attributes - /// This is for sampling purposes only. - /// - public class PeopleController : WebApiController, IDisposable - { - private readonly AppDbContext _dbContext = new AppDbContext(); - private const string RelativePath = "/api/"; - - public PeopleController(IHttpContext context) - : base(context) - { - } - - /// - /// Gets the people. - /// This will respond to - /// GET http://localhost:9696/api/people/ - /// GET http://localhost:9696/api/people/1 - /// GET http://localhost:9696/api/people/{n} - /// - /// - /// Key Not Found: + lastSegment - [WebApiHandler(HttpVerbs.Get, RelativePath + "people/{id?}")] - public async Task GetPeople(string id = null) - { - // if it ends with a / means we need to list people - if (string.IsNullOrWhiteSpace(id)) - return await Ok(_dbContext.People.SelectAll()); - - // if it ends with "first" means we need to show first record of people - if (id == "first") - return await Ok(_dbContext.People.SelectAll().First()); - - // otherwise, we need to parse the key and respond with the entity accordingly - if (int.TryParse(id, out var key)) - { - var single = await _dbContext.People.SingleAsync(key); - - if (single != null) - return await Ok(single); - } - - throw new KeyNotFoundException($"Key Not Found: {id}"); - } - - /// - /// Posts the people Tubular model. - /// - /// - [WebApiHandler(HttpVerbs.Post, RelativePath + "people/")] - public Task PostPeople() => - Ok(async (model, ct) => - model.CreateGridDataResponse((await _dbContext.People.SelectAllAsync()).AsQueryable())); - - /// - /// Echoes the request form data in JSON format - /// - /// - [WebApiHandler(HttpVerbs.Post, RelativePath + "echo/")] - public async Task Echo() - { - var content = await HttpContext.RequestFormDataDictionaryAsync(); - - return await Ok(content); - } - - /// - public void Dispose() => _dbContext?.Dispose(); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/Program.cs b/src/Unosquare.Labs.EmbedIO.Samples/Program.cs deleted file mode 100644 index 8f9734502..000000000 --- a/src/Unosquare.Labs.EmbedIO.Samples/Program.cs +++ /dev/null @@ -1,128 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using Modules; - using System.Diagnostics; - using System.IO; - using System.Reflection; - using Swan; - using System; - using System.Threading; - using System.Threading.Tasks; - - internal class Program - { - /// - /// Defines the entry point of the application. - /// - /// The arguments. - private static async Task Main(string[] args) - { - var url = args.Length > 0 ? args[0] : "http://*:8877"; - - AppDbContext.InitDatabase(); - - var ctSource = new CancellationTokenSource(); - ctSource.Token.Register(() => "Shutting down".Info(nameof(Main))); - - // Set a task waiting for press key to exit -#pragma warning disable 4014 - Task.Run(() => -#pragma warning restore 4014 - { - // Wait for any key to be pressed before disposing of our web server. - Console.ReadLine(); - - ctSource.Cancel(); - }, ctSource.Token); - - var webOptions = new WebServerOptions(url) { Mode = HttpListenerMode.EmbedIO }; - - // Our web server is disposable. - using (var server = new WebServer(webOptions)) - { - // Report to console the error only - server.UnhandledException = (ctx, ex, ct) => { - ex.Message.Error(nameof(WebServer)); - ctx.Response.StatusCode = 500; - - return Task.FromResult(true); - }; - - // Listen for state changes. - server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info(); - - // First, we will configure our web server by adding Modules. - // Please note that order DOES matter. - // ================================================================================================ - // If we want to enable sessions, we simply register the LocalSessionModule - // Beware that this is an in-memory session storage mechanism so, avoid storing very large objects. - // You can use the server.GetSession() method to get the SessionInfo object and manipulate it. - server.RegisterModule(new LocalSessionModule()); - - // Set the CORS Rules - server.RegisterModule(new CorsModule( - // Origins, separated by comma without last slash - "http://unosquare.github.io,http://run.plnkr.co", - // Allowed headers - "content-type, accept", - // Allowed methods - "post")); - - // Register the static files server. See the html folder of this project. Also notice that - // the files under the html folder have Copy To Output Folder = Copy if Newer - server.RegisterModule(new StaticFilesModule(HtmlRootPath)); - - // Register the Web Api Module. See the Setup method to find out how to do it - // It registers the WebApiModule and registers the controller(s) -- that's all. - server.WithWebApiController(true); - - // Register the WebSockets module. See the Setup method to find out how to do it - // It registers the WebSocketsModule and registers the server for the given paths(s) - server.RegisterModule(new WebSocketsModule()); - server.Module().RegisterWebSocketsServer(); - server.Module().RegisterWebSocketsServer(); - - // Fire up the browser to show the content! - var browser = new Process - { - StartInfo = new ProcessStartInfo(url.Replace("*", "localhost")) - { - UseShellExecute = true - } - }; - - browser.Start(); - - // Once we've registered our modules and configured them, we call the RunAsync() method. - if (!ctSource.IsCancellationRequested) - await server.RunAsync(ctSource.Token); - - // Clean up - "Bye".Info(nameof(Program)); - Terminal.Flush(); - } - } - - /// - /// Gets the HTML root path. - /// - /// - /// The HTML root path. - /// - public static string HtmlRootPath - { - get - { - var assemblyPath = Path.GetDirectoryName(typeof(Program).GetTypeInfo().Assembly.Location); - - // This lets you edit the files without restarting the server. -#if DEBUG - return Path.Combine(Directory.GetParent(assemblyPath).Parent.Parent.FullName, "html"); -#else - // This is when you have deployed the server. - return Path.Combine(assemblyPath, "html"); -#endif - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsChatServer.cs b/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsChatServer.cs deleted file mode 100644 index c9f02acc2..000000000 --- a/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsChatServer.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using System.Linq; - using Modules; - using Swan; - - /// - /// - /// Defines a very simple chat server - /// - [WebSocketHandler("/chat")] - public class WebSocketsChatServer : WebSocketsServer - { - public WebSocketsChatServer() - : base(true) - { - // placeholder - } - - /// - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, - IWebSocketReceiveResult rxResult) - { - foreach (var ws in WebSockets.Where(ws => ws != context)) - { - Send(ws, rxBuffer.ToText()); - } - } - - - /// - public override string ServerName => nameof(WebSocketsChatServer); - - /// - protected override void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - Send(context, "Welcome to the chat room!"); - - foreach (var ws in WebSockets.Where(ws => ws != context)) - { - Send(ws, "Someone joined the chat room."); - } - } - - /// - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, - IWebSocketReceiveResult rxResult) - { - // placeholder - } - - /// - protected override void OnClientDisconnected(IWebSocketContext context) - { - Broadcast("Someone left the chat room."); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsTerminalServer.cs b/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsTerminalServer.cs deleted file mode 100644 index b857ae6a4..000000000 --- a/src/Unosquare.Labs.EmbedIO.Samples/WebSocketsTerminalServer.cs +++ /dev/null @@ -1,132 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Samples -{ - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using Modules; - using Swan; - - /// - /// - /// Define a command-line interface terminal - /// - [WebSocketHandler("/terminal")] - public class WebSocketsTerminalServer : WebSocketsServer - { - // we'll keep track of the processes here - private readonly Dictionary _processes = new Dictionary(); - - // The SyncRoot is used to send 1 thing at a time and multi-threaded Processes dictionary. - private readonly object _syncRoot = new object(); - - /// - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, - IWebSocketReceiveResult rxResult) - { - lock (_syncRoot) - { - var arg = rxBuffer.ToText(); - _processes[context].StandardInput.WriteLine(arg); - } - } - - /// - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, - IWebSocketReceiveResult rxResult) - { - // don't process partial frames - } - - /// - /// Finds the context given the process. - /// - /// The p. - /// - private IWebSocketContext FindContext(Process p) - { - lock (_syncRoot) - { - foreach (var kvp in _processes.Where(kvp => kvp.Value == p)) - { - return kvp.Key; - } - } - - return null; - } - - /// - protected override void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - var process = new Process - { - EnableRaisingEvents = true, - StartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - ErrorDialog = false, - FileName = "cmd.exe", - RedirectStandardError = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - UseShellExecute = false, - WorkingDirectory = "c:\\" - } - }; - - process.OutputDataReceived += (s, e) => SendBuffer(s, e.Data); - - process.ErrorDataReceived += (s, e) => SendBuffer(s, e.Data); - - process.Exited += (s, e) => - { - lock (_syncRoot) - { - var ws = FindContext(s as Process); - - if (ws != null && ws.WebSocket.State == Net.WebSocketState.Open) - ws.WebSocket.CloseAsync().GetAwaiter().GetResult(); - } - }; - - // add the process to the context - lock (_syncRoot) - { - _processes[context] = process; - } - - process.Start(); - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - - } - - /// - protected override void OnClientDisconnected(IWebSocketContext context) - { - lock (_syncRoot) - { - if (!_processes[context].HasExited) - _processes[context].Kill(); - } - } - - /// - public override string ServerName => nameof(WebSocketsTerminalServer); - - private void SendBuffer(object s, string buffer) - { - lock (_syncRoot) - { - if ((s as Process)?.HasExited == true) return; - var ws = FindContext(s as Process); - - if (ws != null && ws.WebSocket.State == Net.WebSocketState.Open) - Send(ws, buffer); - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/ICookieCollection.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/ICookieCollection.cs deleted file mode 100644 index daecc6598..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/ICookieCollection.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System.Net; - using System.Collections; - - /// - /// - /// Interface for Cookie Collection. - /// - /// - public interface ICookieCollection : ICollection - { - /// - /// Gets the with the specified name. - /// - /// - /// The . - /// - /// The name. - /// The cookie matching the specified name. - Cookie this[string name] { get; } - - /// - /// Adds the specified cookie. - /// - /// The cookie. - void Add(Cookie cookie); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpContext.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpContext.cs deleted file mode 100644 index 807f18032..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IHttpContext.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System.Threading.Tasks; - using System.Security.Principal; - using System.Collections.Generic; - - /// - /// Interface to create a HTTP Context. - /// - public interface IHttpContext - { - /// - /// Gets the HTTP Request. - /// - /// - /// The request. - /// - IHttpRequest Request { get; } - - /// - /// Gets the HTTP Response. - /// - /// - /// The response. - /// - IHttpResponse Response { get; } - - /// - /// Gets the user. - /// - /// - /// The user. - /// - IPrincipal User { get; } - - /// - /// Gets or sets the web server. - /// - /// - /// The web server. - /// - IWebServer WebServer { get; set; } - - /// - /// Gets or sets the dictionary of data to pass trough the EmbedIO pipeline. - /// - /// - /// The items. - /// - IDictionary Items { get; set; } - - /// - /// Accepts the web socket asynchronous. - /// - /// Size of the receive buffer. - /// The sub protocol. - /// - /// A that represents - /// the WebSocket handshake request. - /// - Task AcceptWebSocketAsync(int receiveBufferSize, string subProtocol = null); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/ISessionWebModule.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/ISessionWebModule.cs deleted file mode 100644 index 6c0304549..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/ISessionWebModule.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using System.Collections.Generic; - - /// - /// - /// Interface to create session modules. - /// - public interface ISessionWebModule : IWebModule - { - /// - /// The dictionary holding the sessions - /// Direct access is guaranteed to be thread-safe. - /// - /// - /// The sessions. - /// - IReadOnlyDictionary Sessions { get; } - - /// - /// Gets or sets the expiration time for the sessions. - /// - /// - /// The expiration. - /// - TimeSpan Expiration { get; set; } - - /// - /// Gets a session object for the given server context. - /// If no session exists for the context, then null is returned. - /// - /// The context. - /// A session info for the given server context. - SessionInfo GetSession(IHttpContext context); - - /// - /// Delete the session object for the given context - /// If no session exists for the context, then null is returned. - /// - /// The context. - void DeleteSession(IHttpContext context); - - /// - /// Delete a session for the given session info - /// No exceptions are thrown if the session is not found. - /// - /// The session info. - void DeleteSession(SessionInfo session); - - /// - /// Gets a session object for the given WebSocket context. - /// If no session exists for the context, then null is returned. - /// - /// The context. - /// A session object for the given WebSocket context. - SessionInfo GetSession(IWebSocketContext context); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebModule.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/IWebModule.cs deleted file mode 100644 index 777bb2030..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebModule.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using System; - using System.Threading; - - /// - /// Interface to create web modules. - /// - public interface IWebModule - { - /// - /// Gets the friendly name of the module. - /// - /// - /// The name. - /// - [Obsolete("Name will be dropped in future versions")] - string Name { get; } - - /// - /// Gets the registered handlers. - /// - /// - /// The handlers. - /// - [Obsolete("Server will be dropped in future versions")] - ModuleMap Handlers { get; } - - /// - /// Gets the associated Web Server object. - /// This property is automatically set when the module is registered. - /// - /// - /// The server. - /// - [Obsolete("Server will be dropped in future versions")] - IWebServer Server { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is watchdog enabled. - /// - /// - /// true if this instance is watchdog enabled; otherwise, false. - /// - [Obsolete("Watchdog will be dropped in future versions")] - bool IsWatchdogEnabled { get; set; } - - /// - /// Gets or sets the watchdog interval. - /// - /// - /// The watchdog interval. - /// - [Obsolete("Watchdog will be dropped in future versions")] - TimeSpan WatchdogInterval { get; set; } - - /// - /// Gets or sets the cancellation token. - /// - /// - /// The cancellation token. - /// - CancellationToken CancellationToken { get; } - - /// - /// Adds a handler that gets called when a path and verb are matched. - /// - /// The path. - /// The verb. - /// The handler. - /// - /// path - /// or - /// handler. - /// - [Obsolete("WebHandler will be dropped in future versions")] - void AddHandler(string path, HttpVerbs verb, WebHandler handler); - - /// - /// Starts the Web Module. - /// - /// The cancellation token. - void Start(CancellationToken ct); - - /// - /// Runs the watchdog. - /// - [Obsolete("Watchdog will be dropped in future versions")] - void RunWatchdog(); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebServer.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/IWebServer.cs deleted file mode 100644 index a736198f7..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebServer.cs +++ /dev/null @@ -1,117 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using Core; - using Constants; - using System.Threading; - using System.Collections.ObjectModel; - using System.Threading.Tasks; - - /// - /// Interface to create a WebServer class. - /// - /// The basic behaviour for a WebServer is register/unregister modules and - /// run asynchronous to receive incoming HTTP Requests. - /// - public interface IWebServer - { - /// - /// Occurs when [state changed]. - /// - event WebServerStateChangedEventHandler StateChanged; - - /// - /// Gets registered SessionModule (if any). - /// - /// SessionModule is an implementation of ISessionModule - /// to handle session data. - /// - /// - /// The session module. - /// - ISessionWebModule SessionModule { get; } - - /// - /// Gets the URL RoutingStrategy used in this instance. - /// - /// By default it is set to Wildcard, but Regex is the recommended value. - /// - /// - /// The routing strategy. - /// - RoutingStrategy RoutingStrategy { get; } - - /// - /// Gets a list of registered modules. - /// - /// - /// The modules. - /// - ReadOnlyCollection Modules { get; } - - /// - /// Gets or sets the on method not allowed. - /// - /// - /// The on method not allowed. - /// - [Obsolete("OnMethodNotAllowed will be dropped in future versions")] - Func> OnMethodNotAllowed { get; set; } - - /// - /// Gets or sets the on not found. - /// - /// - /// The on not found. - /// - [Obsolete("OnNotFound will be dropped in future versions")] - Func> OnNotFound { get; set; } - - /// - /// Gets or sets the unhandled exception. - /// - /// - /// The unhandled exception. - /// - [Obsolete("UnhandledException will be dropped in future versions")] - Func> UnhandledException { get; set; } - - /// - /// Gets the state. - /// - /// - /// The state. - /// - WebServerState State { get; } - - /// - /// Gets the module registered for the given type. - /// Returns null if no module matches the given type. - /// - /// The type of module. - /// Module registered for the given type. - T Module() - where T : class, IWebModule; - - /// - /// Registers an instance of a web module. Only 1 instance per type is allowed. - /// - /// The module. - void RegisterModule(IWebModule webModule); - - /// - /// Unregisters the module identified by its type. - /// - /// Type of the module. - void UnregisterModule(Type moduleType); - - /// - /// 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 ct = default); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketContext.cs b/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketContext.cs deleted file mode 100644 index ee85eac54..000000000 --- a/src/Unosquare.Labs.EmbedIO/Abstractions/IWebSocketContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - - /// - /// Interface to create a WebSocket Context. - /// - public interface IWebSocketContext - { - /// - /// Gets or sets the web socket. - /// - /// - /// The web socket. - /// - IWebSocket WebSocket { get; } - - /// - /// Gets the cookie collection. - /// - /// - /// The cookie collection. - /// - ICookieCollection CookieCollection { get; } - - /// - /// Gets the request URI. - /// - /// - /// The request URI. - /// - Uri RequestUri { get; } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/AssemblyInfo.cs b/src/Unosquare.Labs.EmbedIO/AssemblyInfo.cs deleted file mode 100644 index 8da592b93..000000000 --- a/src/Unosquare.Labs.EmbedIO/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Unosquare.Labs.EmbedIO.Tests")] \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Constants/HttpHeaders.cs b/src/Unosquare.Labs.EmbedIO/Constants/HttpHeaders.cs deleted file mode 100644 index 04d1c93a6..000000000 --- a/src/Unosquare.Labs.EmbedIO/Constants/HttpHeaders.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Constants -{ - using System; - - /// - /// HTTP Header Constants. - /// - [Obsolete("This constants will be available in the new HttpHeaderNames class")] - public static class HttpHeaders - { - /// - /// Access-Control-Allow-Origin HTTP Header. - /// - public const string AccessControlAllowOrigin = "Access-Control-Allow-Origin"; - - /// - /// Access-Control-Allow-Headers HTTP Header. - /// - public const string AccessControlAllowHeaders = "Access-Control-Allow-Headers"; - - /// - /// Access-Control-Allow-Methods HTTP Header. - /// - public const string AccessControlAllowMethods = "Access-Control-Allow-Methods"; - - /// - /// Origin HTTP Header. - /// - public const string Origin = "Origin"; - - /// - /// Access-Control-Request-Headers HTTP Header. - /// - public const string AccessControlRequestHeaders = "Access-Control-Request-Headers"; - - /// - /// Access-Control-Request-Headers HTTP Method. - /// - public const string AccessControlRequestMethod = "Access-Control-Request-Method"; - - /// - /// The cookie header. - /// - public const string Cookie = "Cookie"; - - /// - /// Accept-Encoding HTTP Header. - /// - public const string AcceptEncoding = "Accept-Encoding"; - - /// - /// Content-Encoding HTTP Header. - /// - public const string ContentEncoding = "Content-Encoding"; - - /// - /// If-Modified-Since HTTP Header. - /// - public const string IfModifiedSince = "If-Modified-Since"; - - /// - /// Cache-Control HTTP Header. - /// - public const string CacheControl = "Cache-Control"; - - /// - /// The Location HTTP header. - /// - public const string Location = "Location"; - - /// - /// Pragma HTTP Header. - /// - public const string Pragma = "Pragma"; - - /// - /// Expires HTTP Header. - /// - public const string Expires = "Expires"; - - /// - /// Last-Modified HTTP Header. - /// - public const string LastModified = "Last-Modified"; - - /// - /// If-None-Match HTTP Header. - /// - public const string IfNotMatch = "If-None-Match"; - - /// - /// ETag HTTP Header. - /// - public const string ETag = "ETag"; - - /// - /// Accept-Ranges HTTP Header. - /// - public const string AcceptRanges = "Accept-Ranges"; - - /// - /// Range HTTP Header. - /// - public const string Range = "Range"; - - /// - /// Content-Range HTTP Header. - /// - public const string ContentRanges = "Content-Range"; - - /// - /// The header compression gzip. - /// - public const string CompressionGzip = "gzip"; - - /// - /// The web socket key. - /// - public const string WebSocketKey = "Sec-WebSocket-Key"; - - /// - /// The web socket version. - /// - public const string WebSocketVersion = "Sec-WebSocket-Version"; - - /// - /// The web socket protocol. - /// - public const string WebSocketProtocol = "Sec-WebSocket-Protocol"; - - /// - /// The web socket extensions. - /// - public const string WebSocketExtensions = "Sec-WebSocket-Extensions"; - - /// - /// The web socket accept. - /// - public const string WebSocketAccept = "Sec-WebSocket-Accept"; - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Constants/Responses.cs b/src/Unosquare.Labs.EmbedIO/Constants/Responses.cs deleted file mode 100644 index 240af001b..000000000 --- a/src/Unosquare.Labs.EmbedIO/Constants/Responses.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Constants -{ - /// - /// Represents common responses Constants. - /// - internal static class Responses - { - internal const string ResponseBaseHtml = "{0}"; - - /// - /// Default Http Status 404 response output. - /// - internal const string Response404Html = "

404 - Not Found

"; - - /// - /// Default Status Http 405 response output. - /// - internal const string Response405Html = "

405 - Method Not Allowed

"; - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Constants/RoutingStrategy.cs b/src/Unosquare.Labs.EmbedIO/Constants/RoutingStrategy.cs deleted file mode 100644 index 1d625860a..000000000 --- a/src/Unosquare.Labs.EmbedIO/Constants/RoutingStrategy.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Constants -{ - using System; - - /// - /// Defines the routing strategy for URL matching - /// This is especially useful for REST service implementations - /// in the WebApi module. - /// - public enum RoutingStrategy - { - /// - /// The wildcard strategy - /// - [Obsolete("Wilcard routing will be dropped in future versions")] - Wildcard, - - /// - /// The Regex strategy, default one - /// - Regex, - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Constants/Strings.cs b/src/Unosquare.Labs.EmbedIO/Constants/Strings.cs deleted file mode 100644 index 8c777a360..000000000 --- a/src/Unosquare.Labs.EmbedIO/Constants/Strings.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Constants -{ - using System; - using System.Globalization; - - /// - /// Defines assembly-wide constants. - /// - internal static class Strings - { - internal const string WebSocketVersion = "13"; - - /// - /// Default Browser time format. - /// - internal const string BrowserTimeFormat = "ddd, dd MMM yyyy HH:mm:ss 'GMT'"; - - /// - /// Default CORS rule. - /// - internal const string CorsWildcard = "*"; - - /// - /// The comma split character for String.Split method calls. - /// - internal static readonly char[] CommaSplitChar = { ',' }; - - /// - /// The cookie split chars for String.Split method calls. - /// - internal static readonly char[] CookieSplitChars = {';', ','}; - - /// - /// The format culture used for header outputs. - /// - internal static CultureInfo StandardCultureInfo { get; } = new CultureInfo("en-US"); - - /// - /// The standard string comparer. - /// - internal static StringComparer StandardStringComparer { get; } = StringComparer.InvariantCultureIgnoreCase; - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Constants/WebServerState.cs b/src/Unosquare.Labs.EmbedIO/Constants/WebServerState.cs deleted file mode 100644 index 534150685..000000000 --- a/src/Unosquare.Labs.EmbedIO/Constants/WebServerState.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Constants -{ - /// - /// Enums the web server state. - /// - public enum WebServerState - { - /// - /// The created state. - /// - Created, - - /// - /// The loading state. - /// - Loading, - - /// - /// The listening state. - /// - Listening, - - /// - /// The stopped state. - /// - Stopped, - } -} diff --git a/src/Unosquare.Labs.EmbedIO/CookieCollection.cs b/src/Unosquare.Labs.EmbedIO/CookieCollection.cs deleted file mode 100644 index 16deca57b..000000000 --- a/src/Unosquare.Labs.EmbedIO/CookieCollection.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using System.Collections; - using System.Net; - - /// - /// Represents a wrapper for System.Net.CookieCollection. - /// - /// - public class CookieCollection - : ICookieCollection - { - private readonly System.Net.CookieCollection _cookieCollection; - - /// - /// Initializes a new instance of the class. - /// - /// The cookie collection. - public CookieCollection(System.Net.CookieCollection cookieCollection) - { - _cookieCollection = cookieCollection; - } - - /// - public int Count => _cookieCollection.Count; - - /// - public bool IsSynchronized => _cookieCollection.IsSynchronized; - - /// - public object SyncRoot => _cookieCollection.SyncRoot; - - /// - public Cookie this[string name] => _cookieCollection[name]; - - /// - public IEnumerator GetEnumerator() => _cookieCollection.GetEnumerator(); - - /// - public void CopyTo(Array array, int index) => _cookieCollection.CopyTo(array, index); - - /// - public void Add(Cookie cookie) => _cookieCollection.Add(cookie); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/FormDataParser.cs b/src/Unosquare.Labs.EmbedIO/Core/FormDataParser.cs deleted file mode 100644 index ba285f0d4..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/FormDataParser.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using Constants; - using System.Collections.Generic; - using System.Linq; - - internal static class FormDataParser - { - /// - /// Parses the form data given the request body string. - /// - /// The request body. - /// The content type header. - /// - /// A collection that represents the request body string. - /// - /// multipart/form-data Content Type parsing is not yet implemented. - internal static Dictionary ParseAsDictionary( - string requestBody, - string contentTypeHeader = MimeTypes.UrlEncodedContentType) - { - if (contentTypeHeader.ToLowerInvariant().StartsWith("multipart/form-data")) - throw new NotImplementedException("multipart/form-data Content Type parsing is not yet implemented"); - - // verify there is data to parse - if (string.IsNullOrWhiteSpace(requestBody)) return null; - - // define a character for KV pairs - var kvpSeparator = new[] {'='}; - - // Create the result object - var resultDictionary = new Dictionary(); - - // Split the request body into key-value pair strings - var keyValuePairStrings = requestBody.Split('&').Where(x => string.IsNullOrWhiteSpace(x) == false); - - foreach (var kvps in keyValuePairStrings) - { - // Split by the equals char into key values. - // Some KVPS will have only their key, some will have both key and value - // Some other might be repeated which really means an array - var kvpsParts = kvps.Split(kvpSeparator, 2); - - // We don't want empty KVPs - if (kvpsParts.Length == 0) - continue; - - // Decode the key and the value. Discard Special Characters - var key = System.Net.WebUtility.UrlDecode(kvpsParts[0]); - if (key.IndexOf("[", StringComparison.OrdinalIgnoreCase) > 0) - key = key.Substring(0, key.IndexOf("[", StringComparison.OrdinalIgnoreCase)); - - var value = kvpsParts.Length >= 2 ? System.Net.WebUtility.UrlDecode(kvpsParts[1]) : null; - - // If the result already contains the key, then turn the value of that key into a List of strings - if (resultDictionary.ContainsKey(key)) - { - // Check if this key has a List value already - if (!(resultDictionary[key] is List listValue)) - { - // if we don't have a list value for this key, then create one and add the existing item - var existingValue = resultDictionary[key] as string; - resultDictionary[key] = new List(); - listValue = (List) resultDictionary[key]; - listValue.Add(existingValue); - } - - // By this time, we are sure listValue exists. Simply add the item - listValue.Add(value); - } - else - { - // Simply set the key to the parsed value - resultDictionary[key] = value; - } - } - - return resultDictionary; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs b/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs deleted file mode 100644 index 3497e54c0..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs +++ /dev/null @@ -1,78 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.IO; - using System.Text.RegularExpressions; - - internal static class PathHelper - { - private static readonly Regex MultipleSlashRegex = new Regex("//+", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly char[] InvalidLocalPathChars = GetInvalidLocalPathChars(); - - // urlPath must be a valid URL path - // (not null, not empty, starting with a slash.) - public static string NormalizeUrlPath(string urlPath, bool isBasePath) - { - // Replace each run of multiple slashes with a single slash - urlPath = MultipleSlashRegex.Replace(urlPath, "/"); - - // The root path needs no further checking. - var length = urlPath.Length; - if (length == 1) - return urlPath; - - // Base URL paths must end with a slash; - // non-base URL paths must NOT end with a slash. - // The final slash is irrelevant for the URL itself - // (it has to map the same way with or without it) - // but makes comparing and mapping URls a lot simpler. - var finalPosition = length - 1; - var endsWithSlash = urlPath[finalPosition] == '/'; - return isBasePath - ? (endsWithSlash ? urlPath : urlPath + "/") - : (endsWithSlash ? urlPath.Substring(0, finalPosition) : urlPath); - } - - public static string EnsureValidUrlPath(string urlPath, bool isBasePath) - { - if (urlPath == null) - throw new InvalidOperationException("URL path is null,"); - - if (urlPath.Length == 0) - throw new InvalidOperationException("URL path is empty."); - - if (urlPath[0] != '/') - throw new InvalidOperationException($"URL path \"{urlPath}\"does not start with a slash."); - - return NormalizeUrlPath(urlPath, isBasePath); - } - - public static string EnsureValidLocalPath(string localPath) - { - if (localPath == null) - throw new InvalidOperationException("Local path is null."); - - if (localPath.Length == 0) - throw new InvalidOperationException("Local path is empty."); - - if (string.IsNullOrWhiteSpace(localPath)) - throw new InvalidOperationException("Local path contains only white space."); - - if (localPath.IndexOfAny(InvalidLocalPathChars) >= 0) - throw new InvalidOperationException($"Local path \"{localPath}\"contains one or more invalid characters."); - - return localPath; - } - - private static char[] GetInvalidLocalPathChars() - { - var systemChars = Path.GetInvalidPathChars(); - var p = systemChars.Length; - var result = new char[p + 2]; - Array.Copy(systemChars, result, p); - result[p++] = '*'; - result[p] = '?'; - return result; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs b/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs deleted file mode 100644 index 62b6ebdc2..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - - [Flags] - internal enum PathMappingResult - { - /// - /// The mask used to extract the mapping result. - /// - MappingMask = 0xF, - - /// - /// The path was not found. - /// - NotFound = 0, - - /// - /// The path was mapped to a file. - /// - IsFile = 0x1, - - /// - /// The path was mapped to a directory. - /// - IsDirectory = 0x2, - - /// - /// The default extension has been appended to the path. - /// - DefaultExtensionUsed = 0x1000, - - /// - /// The default document name has been appended to the path. - /// - DefaultDocumentUsed = 0x2000, - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/RamCache.cs b/src/Unosquare.Labs.EmbedIO/Core/RamCache.cs deleted file mode 100644 index 9bf47a068..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/RamCache.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.Collections.Concurrent; - using System.IO; - using Swan; - - internal class RamCache : ConcurrentDictionary - { - internal void Add(Stream buffer, string localPath, DateTime fileDate) - { - using (var memoryStream = new MemoryStream()) - { - buffer.Position = 0; - buffer.CopyTo(memoryStream); - - this[localPath] = new RamCacheEntry - { - LastModified = fileDate, - Buffer = memoryStream.ToArray(), - }; - } - } - - internal bool IsValid(string requestFullLocalPath, DateTime fileDate, out string currentHash) - { - if (ContainsKey(requestFullLocalPath) && this[requestFullLocalPath].LastModified == fileDate) - { - currentHash = this[requestFullLocalPath].Buffer.ComputeMD5().ToUpperHex() + '-' + - fileDate.Ticks; - - return true; - } - - currentHash = string.Empty; - return false; - } - - /// - /// Represents a RAM Cache dictionary entry. - /// - internal class RamCacheEntry - { - public DateTime LastModified { get; set; } - public byte[] Buffer { get; set; } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/RegexCache.cs b/src/Unosquare.Labs.EmbedIO/Core/RegexCache.cs deleted file mode 100644 index 5cebe5d16..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/RegexCache.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System.Collections.Concurrent; - using System.Text.RegularExpressions; - - internal static class RegexCache - { - private const string RegexRouteReplace = "([^//]*)"; - - private const string WildcardRouteReplace = "(.*)"; - - private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(); - - private static readonly Regex RouteParamRegex = new Regex(@"\{[^\/]*\}", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - internal static Match MatchRegexStrategy(string url, string input) - { - if (!Cache.TryGetValue(url, out var regex)) - { - regex = new Regex( - string.Concat("^", RouteParamRegex.Replace(url, RegexRouteReplace), "$"), - RegexOptions.IgnoreCase); - - Cache.TryAdd(url, regex); - } - - return regex.Match(input); - } - - internal static Match MatchWildcardStrategy(string url, string input) - { - if (!Cache.TryGetValue(url, out var regex)) - { - regex = new Regex(url.Replace("*", WildcardRouteReplace)); - - Cache.TryAdd(url, regex); - } - - return regex.Match(input); - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs b/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs deleted file mode 100644 index 914cf0fa1..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.Collections.Generic; - - // Sorts strings in reverse order to obtain the evaluation order of virtual paths - internal sealed class ReverseOrdinalStringComparer : IComparer - { - private static readonly IComparer DirectComparer = StringComparer.Ordinal; - - private ReverseOrdinalStringComparer() - { - } - - public static IComparer Instance { get; } = new ReverseOrdinalStringComparer(); - - public int Compare(string x, string y) => DirectComparer.Compare(y, x); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs b/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs deleted file mode 100644 index 34ee1060f..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs +++ /dev/null @@ -1,101 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.IO; - - internal sealed class VirtualPath - { - public VirtualPath(string baseUrlPath, string baseLocalPath) - { - BaseUrlPath = PathHelper.EnsureValidUrlPath(baseUrlPath, true); - try - { - BaseLocalPath = Path.GetFullPath(PathHelper.EnsureValidLocalPath(baseLocalPath)); - } -#pragma warning disable CA1031 - catch (Exception e) - { - throw new InvalidOperationException($"Cannot determine the full local path for \"{baseLocalPath}\".", e); - } -#pragma warning restore CA1031 - } - - public string BaseUrlPath { get; } - - public string BaseLocalPath { get; } - - // Base paths are forced to end with a slash, - // while requested paths are forced to NOT end with a slash. - // Virtual path "/media/" can map "/media/file.jpg" - // but it can also map "/media" (without the slash). - - internal bool CanMapUrlPath(string urlPath) - => urlPath.StartsWith(BaseUrlPath, StringComparison.Ordinal) - || (urlPath.Length == BaseUrlPath.Length - 1 && BaseUrlPath.StartsWith(urlPath, StringComparison.Ordinal)); - - internal bool TryMapUrlPathLoLocalPath(string urlPath, out string localPath) - { - if (!CanMapUrlPath(urlPath)) - { - localPath = null; - return false; - } - - // The only case where CanMapUrlPath returns true for a path shorter than BaseUrlPath - // is urlPath == (BaseUrlPath minus the final slash). - var relativeUrlPath = urlPath.Length < BaseUrlPath.Length - ? string.Empty - : urlPath.Substring(BaseUrlPath.Length); - - // 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(relativeUrlPath)) - { - localPath = null; - return false; - } - - // 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(BaseLocalPath, relativeUrlPath.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. - localPath = null; - return false; - } -#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(BaseLocalPath, StringComparison.Ordinal)) - { - localPath = null; - return false; - } - - return true; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs b/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs deleted file mode 100644 index 0c6a5cf81..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs +++ /dev/null @@ -1,373 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.IO; - using System.Linq; - using System.Threading; - - internal sealed class VirtualPathManager : IDisposable - { - public const string DefaultDocumentName = "index.html"; - - private const string RootUrlPath = "/"; - - private readonly SortedDictionary _virtualPaths = new SortedDictionary(ReverseOrdinalStringComparer.Instance); - - private readonly VirtualPath _rootPath; - - private readonly ReaderWriterLockSlim _access = new ReaderWriterLockSlim(); - - private readonly ConcurrentDictionary _pathCache = new ConcurrentDictionary(); - - private string _defaultExtension; - - private string _defaultDocument = DefaultDocumentName; - - public VirtualPathManager(string rootLocalPath, bool canMapDirectories, bool cachePaths) - { - rootLocalPath = PathHelper.EnsureValidLocalPath(rootLocalPath); - _rootPath = new VirtualPath(RootUrlPath, rootLocalPath); - CanMapDirectories = canMapDirectories; - CachePaths = cachePaths; - } - - ~VirtualPathManager() - { - Dispose(false); - } - - public string RootLocalPath => _rootPath.BaseLocalPath; - - public bool CanMapDirectories { get; } - - public bool CachePaths { get; } - - public string DefaultExtension - { - get - { - _access.EnterReadLock(); - try - { - return _defaultExtension; - } - finally - { - _access.ExitReadLock(); - } - } - set - { - if (string.IsNullOrEmpty(value)) - { - value = null; - } - else if (value[0] != '.') - { - throw new InvalidOperationException("The default extension, if any, must start with a dot."); - } - - if (string.Equals(value, _defaultExtension, StringComparison.Ordinal)) - return; - - _access.EnterWriteLock(); - try - { - _defaultExtension = value; - - // Discard cache entries for which the previous default extension was used. - // If / when requested again, the new default extension will be used. - var keys = _pathCache - .Where(p => (p.Value.MappingResult & PathMappingResult.DefaultExtensionUsed) != 0) - .Select(p => p.Key) - .ToArray(); - foreach (var key in keys) - { - _pathCache.TryRemove(key, out _); - } - } - finally - { - _access.ExitWriteLock(); - } - } - } - - public string DefaultDocument - { - get - { - _access.EnterReadLock(); - try - { - return _defaultDocument; - } - finally - { - _access.ExitReadLock(); - } - } - set - { - if (string.IsNullOrEmpty(value)) - { - value = null; - } - - if (string.Equals(value, _defaultDocument, StringComparison.Ordinal)) - return; - - _access.EnterWriteLock(); - try - { - _defaultDocument = value; - - // Discard cache entries for which the previous default document was used. - // If / when requested again, the new default document will be used. - var keys = _pathCache - .Where(p => (p.Value.MappingResult & PathMappingResult.DefaultDocumentUsed) != 0) - .Select(p => p.Key) - .ToArray(); - foreach (var key in keys) - { - _pathCache.TryRemove(key, out _); - } - } - finally - { - _access.ExitWriteLock(); - } - } - } - - public ReadOnlyDictionary VirtualPaths - { - get - { - IDictionary dictionary; - - _access.EnterReadLock(); - try - { - dictionary = _virtualPaths.Values.ToDictionary(p => p.BaseUrlPath, p => p.BaseLocalPath); - } - finally - { - _access.ExitReadLock(); - } - - return new ReadOnlyDictionary(dictionary); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public void RegisterVirtualPath(string virtualPath, string physicalPath) - { - virtualPath = PathHelper.EnsureValidUrlPath(virtualPath, true); - - if (virtualPath == RootUrlPath) - throw new InvalidOperationException($"The virtual path {RootUrlPath} is invalid."); - - physicalPath = PathHelper.EnsureValidLocalPath(physicalPath); - - _access.EnterWriteLock(); - try - { - if (_virtualPaths.ContainsKey(virtualPath)) - throw new InvalidOperationException($"The virtual path {virtualPath} already exists."); - - var vp = new VirtualPath(virtualPath, physicalPath); - _virtualPaths.Add(virtualPath, vp); - - // Remove URL paths that could be mapped by the new virtual path, - // but were mapped by either a shorter virtual path, or the root path, - // from the mapped paths cache. - // If / when requested again, those paths can now be mapped by the newly-added virtual path. - var keys = _pathCache - .Where(p => vp.CanMapUrlPath(p.Key) && p.Value.BaseUrlPath.Length < virtualPath.Length) - .Select(p => p.Key) - .ToArray(); - foreach (var key in keys) - { - _pathCache.TryRemove(key, out _); - } - } - finally - { - _access.ExitWriteLock(); - } - } - - public void UnregisterVirtualPath(string virtualPath) - { - virtualPath = PathHelper.EnsureValidUrlPath(virtualPath, true); - - _access.EnterWriteLock(); - try - { - if (!_virtualPaths.ContainsKey(virtualPath)) - throw new InvalidOperationException($"The virtual path {virtualPath} does not exist."); - - _virtualPaths.Remove(virtualPath); - - // Remove paths mapped by this virtual path - // from the mapped paths cache. - // If / when requested again, those paths will be mapped - // by either a shorter virtual path, or the root path. - var keys = _pathCache - .Where(p => string.Equals(virtualPath, p.Value.BaseUrlPath, StringComparison.Ordinal)) - .Select(p => p.Key) - .ToArray(); - foreach (var key in keys) - { - _pathCache.TryRemove(key, out _); - } - } - finally - { - _access.ExitWriteLock(); - } - } - - public PathMappingResult MapUrlPath(string urlPath, out string localPath) - { - urlPath = PathHelper.NormalizeUrlPath(urlPath, false); - var result = CachePaths ? _pathCache.GetOrAdd(urlPath, MapUrlPathCore) : MapUrlPathCore(urlPath); - localPath = result.LocalPath; - return result.MappingResult; - } - - private void Dispose(bool disposing) - { - if (disposing) - { - _access.Dispose(); - } - - _pathCache.Clear(); - } - - private PathCacheItem MapUrlPathCore(string urlPath) - { - _access.EnterReadLock(); - try - { - var localPath = MapUrlPathToLocalPath(urlPath, out var baseUrlPath); - // Error 404 on failed mapping. - var validationResult = localPath == null - ? PathMappingResult.NotFound - : ValidateLocalPath(ref localPath); - return new PathCacheItem(baseUrlPath, localPath, validationResult); - } - finally - { - _access.ExitReadLock(); - } - } - - private string MapUrlPathToLocalPath(string urlPath, out string baseUrlPath) - { - // Assuming that urlPath is not null, not empty, and starts with a slash, - // a length lower than 2 can only mean that the path is "/". - // Bail out early, because we need at least a length of 2 - // for the optimizations below to work. - if (urlPath.Length < 2) - { - baseUrlPath = RootUrlPath; - return _rootPath.BaseLocalPath; - } - - string localPath; - - // First try to use each virtual path in reverse ordinal order - // (so e.g. "/media/images" is evaluated before "/media".) - // As long as we keep checks simple, we can try to optimize the loop a little. - // The second character of a URL path is the first character following the initial slash; - // by checking just that, we can avoid some useless calls to TryMapUrlPathLoLocalPath. - var secondChar = urlPath[1]; - foreach (var virtualPath in _virtualPaths.Values) - { - var baseSecondChar = virtualPath.BaseUrlPath[1]; - if (baseSecondChar == secondChar) - { - // If the second character is the same, try mapping. - if (virtualPath.TryMapUrlPathLoLocalPath(urlPath, out localPath)) - { - baseUrlPath = virtualPath.BaseUrlPath; - return localPath; - } - } - else if (baseSecondChar < secondChar) - { - // If we have reached a base URL path with a second character - // with a lower value than ours, we can safely bail out of the loop. - break; - } - } - - // If no virtual path can map our URL path, use the root path. - // This will fail only for invalid paths. - if (_rootPath.TryMapUrlPathLoLocalPath(urlPath, out localPath)) - { - baseUrlPath = RootUrlPath; - return localPath; - } - - baseUrlPath = RootUrlPath; - return null; - } - - private PathMappingResult ValidateLocalPath(ref string localPath) - { - if (File.Exists(localPath)) - return PathMappingResult.IsFile; - - if (Directory.Exists(localPath)) - { - if (CanMapDirectories) - return PathMappingResult.IsDirectory; - - if (_defaultDocument != null) - { - localPath = Path.Combine(localPath, _defaultDocument); - if (File.Exists(localPath)) - return PathMappingResult.IsFile | PathMappingResult.DefaultDocumentUsed; - } - } - - if (_defaultExtension != null) - { - localPath += _defaultExtension; - if (File.Exists(localPath)) - return PathMappingResult.IsFile | PathMappingResult.DefaultExtensionUsed; - } - - localPath = null; - return PathMappingResult.NotFound; - } - - private struct PathCacheItem - { - public readonly string BaseUrlPath; - - public readonly string LocalPath; - - public readonly PathMappingResult MappingResult; - - public PathCacheItem(string baseUrlPath, string localPath, PathMappingResult mappingResult) - { - BaseUrlPath = baseUrlPath; - LocalPath = localPath; - MappingResult = mappingResult; - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/EasyRoutes.cs b/src/Unosquare.Labs.EmbedIO/EasyRoutes.cs deleted file mode 100644 index 40458bf5d..000000000 --- a/src/Unosquare.Labs.EmbedIO/EasyRoutes.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - - /// - /// Extension methods to add easily routes to a IWebServer. - /// - public static class EasyRoutes - { - /// - /// Called when any unhandled request. - /// Any verb and any path. - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnAny(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Any, action); - - /// - /// Called when any POST unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnPost(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Post, action); - - /// - /// Called when any GET unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnGet(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Get, action); - - /// - /// Called when any PUT unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnPut(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Put, action); - - /// - /// Called when any DELETE unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnDelete(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Delete, action); - - /// - /// Called when any HEAD unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnHead(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Head, action); - - /// - /// Called when any OPTIONS unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnOptions(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Options, action); - - /// - /// Called when any PATCH unhandled request (any path). - /// - /// The webserver. - /// The action. - /// - /// The webserver instance. - /// - /// webserver. - public static IWebServer OnPatch(this IWebServer webserver, WebHandler action) - => webserver.WithAction(ModuleMap.AnyPath, Constants.HttpVerbs.Patch, action); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Extensions.Fluent.cs b/src/Unosquare.Labs.EmbedIO/Extensions.Fluent.cs deleted file mode 100644 index 1e2c41a77..000000000 --- a/src/Unosquare.Labs.EmbedIO/Extensions.Fluent.cs +++ /dev/null @@ -1,268 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using Modules; - using System; - using System.Collections.Generic; - using Swan; - using System.Linq; - using System.Reflection; - - /// - /// Extensions methods to EmbedIO's Fluent Interface. - /// - public static partial class Extensions - { - /// - /// Add the StaticFilesModule to the specified WebServer. - /// - /// The webserver instance. - /// The static folder path. - /// The default document name. - /// if set to true [use directory browser]. - /// - /// An instance of webserver. - /// - /// webserver. - public static IWebServer WithStaticFolderAt( - this IWebServer webserver, - string rootPath, - string defaultDocument = StaticFilesModule.DefaultDocumentName, - bool useDirectoryBrowser = false) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule( - new StaticFilesModule(rootPath, useDirectoryBrowser) {DefaultDocument = defaultDocument}); - return webserver; - } - - /// - /// Add the StaticFilesModule with multiple paths. - /// - /// The webserver. - /// The virtual paths. - /// The default document. - /// An instance of a web module. - /// webserver. - public static IWebServer WithVirtualPaths( - this IWebServer webserver, - Dictionary virtualPaths, - string defaultDocument = StaticFilesModule.DefaultDocumentName) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule(new StaticFilesModule(virtualPaths) {DefaultDocument = defaultDocument}); - return webserver; - } - - /// - /// Add StaticFilesModule to WebServer. - /// - /// The webserver instance. - /// An instance of a web module. - /// webserver. - public static IWebServer WithLocalSession(this IWebServer webserver) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule(new LocalSessionModule()); - return webserver; - } - - /// - /// Add WebApiModule to WebServer. - /// - /// The webserver instance. - /// The assembly to load WebApi Controllers from. Leave null to avoid autoloading. - /// if set to true [response json exception]. - /// - /// An instance of webserver. - /// - /// webserver. - public static IWebServer WithWebApi(this IWebServer webserver, Assembly assembly = null, bool responseJsonException = false) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule(new WebApiModule()); - return assembly != null ? webserver.LoadApiControllers(assembly, responseJsonException) : webserver; - } - - /// - /// Add WebSocketsModule to WebServer. - /// - /// The webserver instance. - /// The assembly to load Web Sockets from. Leave null to avoid autoloading. - /// An instance of webserver. - /// webserver. - public static IWebServer WithWebSocket(this IWebServer webserver, Assembly assembly = null) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule(new WebSocketsModule()); - return assembly != null ? webserver.LoadWebSockets(assembly) : webserver; - } - - /// - /// Load all the WebApi Controllers in an assembly. - /// - /// The webserver instance. - /// The assembly to load WebApi Controllers from. Leave null to load from the currently executing assembly. - /// if set to true [response json exception]. - /// - /// An instance of webserver. - /// - /// webserver. - /// webserver. - public static IWebServer LoadApiControllers(this IWebServer webserver, Assembly assembly = null, bool responseJsonException = false) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - var types = (assembly ?? Assembly.GetEntryAssembly()).GetTypes(); - var apiControllers = types - .Where(x => x.GetTypeInfo().IsClass - && !x.GetTypeInfo().IsAbstract - && x.GetTypeInfo().IsSubclassOf(typeof(WebApiController))) - .ToArray(); - - foreach (var apiController in apiControllers) - { - if (webserver.Module() == null) - webserver = webserver.WithWebApi(responseJsonException: responseJsonException); - - webserver.Module().RegisterController(apiController); - $"Registering WebAPI Controller '{apiController.Name}'".Debug(nameof(LoadApiControllers)); - } - - return webserver; - } - - /// - /// Load all the WebApi Controllers in an assembly. - /// - /// The Web API Module instance. - /// The assembly to load WebApi Controllers from. Leave null to load from the currently executing assembly. - /// The webserver instance. - /// webserver. - public static WebApiModule LoadApiControllers(this WebApiModule apiModule, Assembly assembly = null) - { - if (apiModule == null) - throw new ArgumentNullException(nameof(apiModule)); - - var types = (assembly ?? Assembly.GetEntryAssembly()).GetTypes(); - var apiControllers = types - .Where(x => x.GetTypeInfo().IsClass - && !x.GetTypeInfo().IsAbstract - && x.GetTypeInfo().IsSubclassOf(typeof(WebApiController))) - .ToArray(); - - foreach (var apiController in apiControllers) - { - apiModule.RegisterController(apiController); - } - - return apiModule; - } - - /// - /// Load all the WebSockets in an assembly. - /// - /// The webserver instance. - /// The assembly to load WebSocketsServer types from. Leave null to load from the currently executing assembly. - /// An instance of webserver. - /// webserver. - public static IWebServer LoadWebSockets(this IWebServer webserver, Assembly assembly = null) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - var types = (assembly ?? Assembly.GetEntryAssembly()).GetTypes(); - - foreach (var socketServer in types.Where(x => x.GetTypeInfo().BaseType == typeof(WebSocketsServer))) - { - if (webserver.Module() == null) webserver = webserver.WithWebSocket(); - - webserver.Module().RegisterWebSocketsServer(socketServer); - $"Registering WebSocket Server '{socketServer.Name}'".Debug(nameof(LoadWebSockets)); - } - - return webserver; - } - - /// - /// Enables CORS in the WebServer. - /// - /// The webserver instance. - /// The valid origins, default all. - /// The valid headers, default all. - /// The valid method, default all. - /// An instance of the tiny web server used to handle request. - /// webserver. - public static IWebServer EnableCors( - this IWebServer webserver, - string origins = Strings.CorsWildcard, - string headers = Strings.CorsWildcard, - string methods = Strings.CorsWildcard) - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - webserver.RegisterModule(new CorsModule(origins, headers, methods)); - - return webserver; - } - - /// - /// Add WebApi Controller to WebServer. - /// - /// The type of Web API Controller. - /// The webserver instance. - /// if set to true [response json exception]. - /// - /// An instance of webserver. - /// - /// webserver. - public static IWebServer WithWebApiController(this IWebServer webserver, bool responseJsonException = false) - where T : WebApiController - { - if (webserver == null) - throw new ArgumentNullException(nameof(webserver)); - - if (webserver.Module() == null) - { - webserver.RegisterModule(new WebApiModule(responseJsonException)); - } - - webserver.Module().RegisterController(); - - return webserver; - } - - /// - /// Creates an instance of and adds it to a module container. - /// - /// The this. - /// The base URL path. - /// The verb. - /// The handler. - /// - /// with a added. - /// - /// webserver - public static IWebServer WithAction(this IWebServer @this, string baseUrlPath, HttpVerbs verb, WebHandler handler) - { - if (@this == null) - throw new ArgumentNullException(nameof(@this)); - - @this.RegisterModule(new ActionModule(baseUrlPath, verb, handler)); - - return @this; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Extensions.Response.cs b/src/Unosquare.Labs.EmbedIO/Extensions.Response.cs deleted file mode 100644 index 11c70d4a9..000000000 --- a/src/Unosquare.Labs.EmbedIO/Extensions.Response.cs +++ /dev/null @@ -1,360 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using System.Net; - using Swan.Formatters; - using System; - using System.IO; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Extension methods to help your coding. - /// - public static partial class Extensions - { - /// - /// Sends headers to disable caching on the client side. - /// - /// The context. - public static void NoCache(this IHttpContext context) => context.Response.NoCache(); - - /// - /// Sends headers to disable caching on the client side. - /// - /// The response. - public static void NoCache(this IHttpResponse response) - { - response.AddHeader(HttpHeaderNames.Expires, "Mon, 26 Jul 1997 05:00:00 GMT"); - response.AddHeader(HttpHeaderNames.LastModified, - DateTime.UtcNow.ToString(Strings.BrowserTimeFormat, Strings.StandardCultureInfo)); - response.AddHeader(HttpHeaderNames.CacheControl, "no-store, no-cache, must-revalidate"); - response.AddHeader(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 StandardResponseWithoutBody(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.ContentLength64 = 0; - } - - /// - /// 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 used to cancel the operation. - /// A representing the ongoing operation. - /// is . - /// There is no standard status description for . - /// - public static Task StandardHtmlResponseAsync(this IHttpResponse @this, int statusCode, CancellationToken cancellationToken) - => StandardHtmlResponseAsync(@this, statusCode, null, cancellationToken); - - /// - /// 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 append additional HTML code - /// to the response. If not , the callback is called immediately before - /// closing the HTML body tag. - /// A used to cancel the operation. - /// A representing the ongoing operation. - /// is . - /// There is no standard status description for . - /// - public static Task StandardHtmlResponseAsync( - this IHttpResponse @this, - int statusCode, - Func appendAdditionalHtml, - CancellationToken cancellationToken) - { - 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 = MimeTypes.HtmlType; - var sb = new StringBuilder() - .Append("") - .Append(statusCode) - .Append(" - ") - .Append(statusDescription) - .Append("

") - .Append(statusCode) - .Append(" - ") - .Append(statusDescription) - .Append("

"); - appendAdditionalHtml?.Invoke(sb); - sb.Append(""); - var buffer = Encoding.UTF8.GetBytes(sb.ToString()); - sb = null; // Free some memory if next GC is near - @this.ContentLength64 = buffer.Length; - return @this.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken); - } - - /// - /// Outputs async a Json Response given a data object. - /// - /// The context. - /// The data. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A true value if the response output was set. - /// - public static Task JsonResponseAsync( - this IHttpContext context, - object data, - bool useGzip, - CancellationToken cancellationToken = default) - => context.JsonResponseAsync(Json.Serialize(data), useGzip, cancellationToken); - - /// - /// Outputs async a Json Response given a data object. - /// - /// The context. - /// The data. - /// The cancellation token. - /// - /// A true value if the response output was set. - /// - public static Task JsonResponseAsync( - this IHttpContext context, - object data, - CancellationToken cancellationToken = default) - => context.JsonResponseAsync(Json.Serialize(data), cancellationToken); - - /// - /// Outputs async a JSON Response given a JSON string. - /// - /// The context. - /// The JSON. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static Task JsonResponseAsync( - this IHttpContext context, - string json, - bool useGzip, - CancellationToken cancellationToken = default) - => context.StringResponseAsync(json, cancellationToken: cancellationToken, useGzip: useGzip); - - /// - /// Outputs async a JSON Response given a JSON string. - /// - /// The context. - /// The JSON. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static Task JsonResponseAsync( - this IHttpContext context, - string json, - CancellationToken cancellationToken = default) - => context.StringResponseAsync(json, cancellationToken: cancellationToken); - - /// - /// Outputs a HTML Response given a HTML content. - /// - /// The context. - /// Content of the HTML. - /// The status code. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static Task HtmlResponseAsync( - this IHttpContext context, - string htmlContent, - HttpStatusCode statusCode = HttpStatusCode.OK, - bool useGzip = true, - CancellationToken cancellationToken = default) - { - context.Response.StatusCode = (int)statusCode; - return context.StringResponseAsync(htmlContent, MimeTypes.HtmlType, null, useGzip, cancellationToken); - } - - /// - /// Outputs a JSON Response given an exception. - /// - /// The context. - /// The ex. - /// The status code. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static Task JsonExceptionResponseAsync( - this IHttpContext context, - Exception ex, - HttpStatusCode statusCode = HttpStatusCode.InternalServerError, - bool useGzip = true, - CancellationToken cancellationToken = default) - { - context.Response.StatusCode = (int)statusCode; - return context.JsonResponseAsync(ex, useGzip, cancellationToken); - } - - /// - /// Outputs async a string response given a string. - /// - /// The context. - /// The content. - /// Type of the content. - /// The encoding. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - [Obsolete("This method will be replaced by SendStringAsync")] - public static Task StringResponseAsync( - this IHttpContext context, - string content, - string contentType = MimeTypes.JsonType, - Encoding encoding = null, - bool useGzip = true, - CancellationToken cancellationToken = default) => - context.Response.StringResponseAsync(content, contentType, encoding, useGzip && context.AcceptGzip(content.Length), cancellationToken); - - /// - /// Outputs async a string response given a string. - /// - /// The response. - /// The content. - /// Type of the content. - /// The encoding. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - /// - [Obsolete("This method will be replaced by SendStringAsync")] - public static async Task StringResponseAsync( - this IHttpResponse response, - string content, - string contentType = MimeTypes.JsonType, - Encoding encoding = null, - bool useGzip = false, - CancellationToken cancellationToken = default) - { - response.ContentType = contentType; - - using (var buffer = new MemoryStream((encoding ?? Encoding.UTF8).GetBytes(content))) - return await BinaryResponseAsync(response, buffer, useGzip, cancellationToken).ConfigureAwait(false); - } - - /// - /// Writes a binary response asynchronous. - /// - /// The context. - /// The file. - /// Type of the content. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static Task FileResponseAsync( - this IHttpContext context, - FileInfo file, - string contentType = null, - bool useGzip = true, - CancellationToken cancellationToken = default) - { - context.Response.ContentType = contentType ?? MimeTypes.HtmlType; - - var stream = file.OpenRead(); - return context.BinaryResponseAsync(stream, useGzip, cancellationToken); - } - - /// - /// Writes a binary response asynchronous. - /// - /// The context. - /// The buffer. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - [Obsolete("This method will be replaced by SendStreamAsync")] - public static Task BinaryResponseAsync( - this IHttpContext context, - Stream buffer, - bool useGzip = true, - CancellationToken cancellationToken = default) - => BinaryResponseAsync(context.Response, buffer, useGzip && context.AcceptGzip(buffer.Length), cancellationToken); - - /// - /// Writes a binary response asynchronous. - /// - /// The response. - /// The buffer. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - [Obsolete("This method will be replaced by SendStreamAsync")] - public static async Task BinaryResponseAsync( - this IHttpResponse response, - Stream buffer, - bool useGzip = true, - CancellationToken cancellationToken = default) - { - if (useGzip) - { - buffer = await buffer.CompressAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - response.AddHeader(HttpHeaders.ContentEncoding, HttpHeaders.CompressionGzip); - } - - response.ContentLength64 = buffer.Length; - await response.WriteToOutputStream(buffer, 0, cancellationToken).ConfigureAwait(false); - - return true; - } - - /// - /// Writes to output stream. - /// - /// The response. - /// The buffer. - /// Index of the lower byte. - /// The cancellation token. - /// - /// A task representing the write operation to the stream. - /// - public static async Task WriteToOutputStream( - this IHttpResponse response, - Stream buffer, - long lowerByteIndex = 0, - CancellationToken cancellationToken = default) - { - buffer.Position = lowerByteIndex; - await buffer.CopyToAsync(response.OutputStream, Modules.FileModuleBase.ChunkSize, cancellationToken) - .ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Extensions.cs b/src/Unosquare.Labs.EmbedIO/Extensions.cs deleted file mode 100644 index d5c338e4e..000000000 --- a/src/Unosquare.Labs.EmbedIO/Extensions.cs +++ /dev/null @@ -1,621 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using Core; - using Swan; - using Swan.Formatters; - using System.Net; - using System.Text; - using System; - using System.Collections.Generic; - using System.IO; - using System.IO.Compression; - using System.Linq; - using System.Text.RegularExpressions; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Extension methods to help your coding. - /// - public static partial class Extensions - { - private static readonly byte[] LastByte = { 0x00 }; - - private static readonly Regex RouteOptionalParamRegex = new Regex(@"\{[^\/]*\?\}", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - #region Session Management Methods - - /// - /// Gets the session object associated to the current context. - /// Returns null if the LocalSessionWebModule has not been loaded. - /// - /// The context. - /// A session object for the given server context. - public static SessionInfo GetSession(this IHttpContext context) - => context.WebServer.SessionModule?.GetSession(context); - - /// - /// Deletes the session object associated to the current context. - /// - /// The context. - public static void DeleteSession(this IHttpContext context) - { - context.WebServer.SessionModule?.DeleteSession(context); - } - - /// - /// Deletes the given session object. - /// - /// The context. - /// The session info. - public static void DeleteSession(this IHttpContext context, SessionInfo session) - { - context.WebServer.SessionModule?.DeleteSession(session); - } - - /// - /// Gets the session object associated to the current context. - /// Returns null if the LocalSessionWebModule has not been loaded. - /// - /// The context. - /// The server. - /// A session info for the given websocket context. - public static SessionInfo GetSession(this IWebSocketContext context, IWebServer server) => server.SessionModule?.GetSession(context); - - /// - /// Gets the session. - /// - /// The server. - /// The context. - /// A session info for the given websocket context. - public static SessionInfo GetSession(this IWebServer server, IWebSocketContext context) => server.SessionModule?.GetSession(context); - - #endregion - - #region HTTP Request Helpers - - /// - /// Gets the request path for the specified context. - /// - /// The context. - /// Path for the specified context. - public static string RequestPath(this IHttpContext context) - => context.Request.Url.LocalPath.ToLowerInvariant(); - - /// - /// Gets the request path for the specified context using a wildcard paths to - /// match. - /// - /// The context. - /// The wildcard paths. - /// Path for the specified context. - [Obsolete("Wilcard routing will be dropped in future versions")] - public static string RequestWilcardPath(this IHttpContext context, IEnumerable wildcardPaths) - { - var path = context.Request.Url.LocalPath.ToLowerInvariant(); - - var wildcardMatch = wildcardPaths.FirstOrDefault(p => // wildcard at the end - path.StartsWith(p.Substring(0, p.Length - ModuleMap.AnyPath.Length)) - - // wildcard in the middle so check both start/end - || (path.StartsWith(p.Substring(0, p.IndexOf(ModuleMap.AnyPath, StringComparison.Ordinal))) - && path.EndsWith(p.Substring(p.IndexOf(ModuleMap.AnyPath, StringComparison.Ordinal) + 1)))); - - return string.IsNullOrWhiteSpace(wildcardMatch) ? path : wildcardMatch; - } - - /// - /// Gets the request path for the specified context case sensitive. - /// - /// The context. - /// Path for the specified context. - public static string RequestPathCaseSensitive(this IHttpContext context) - => context.Request.Url.LocalPath; - - /// - /// Retrieves the Request HTTP Verb (also called Method) of this context. - /// - /// The context. - /// HTTP verb result of the conversion of this context. - [Obsolete("RequestVerb() will be replaced by Request.HttpVerb in future versions")] - public static HttpVerbs RequestVerb(this IHttpContext context) - { - Enum.TryParse(context.Request.HttpMethod.Trim(), true, out HttpVerbs verb); - return verb; - } - - /// - /// Gets the value for the specified query string key. - /// If the value does not exist it returns null. - /// - /// The context. - /// The key. - /// A string that represents the value for the specified query string key. - public static string QueryString(this IHttpContext context, string key) - => context.InQueryString(key) ? context.Request.QueryString[key] : null; - - /// - /// Determines if a key exists within the Request's query string. - /// - /// The context. - /// The key. - /// true if a key exists within the Request's query string; otherwise, false. - public static bool InQueryString(this IHttpContext context, string key) - => context.Request.QueryString.AllKeys.Contains(key); - - /// - /// Retrieves the specified request the header. - /// - /// The context. - /// Name of the header. - /// Specified request the header when is true; otherwise, empty string. - public static string RequestHeader(this IHttpContext context, string headerName) - => context.Request.Headers[headerName] ?? string.Empty; - - /// - /// Determines whether [has request header] [the specified context]. - /// - /// The context. - /// Name of the header. - /// true if request headers is not a null; otherwise, false. - public static bool HasRequestHeader(this IHttpContext context, string headerName) - => context.Request.Headers[headerName] != null; - - /// - /// Retrieves the request body as a string. - /// Note that once this method returns, the underlying input stream cannot be read again as - /// it is not rewindable for obvious reasons. This functionality is by design. - /// - /// The context. - /// - /// A task with the rest of the stream as a string, from the current position to the end. - /// If the current position is at the end of the stream, returns an empty string. - /// - public static Task RequestBodyAsync(this IHttpContext context) => - context.Request.RequestBodyAsync(); - - /// - /// Retrieves the request body as a string. - /// Note that once this method returns, the underlying input stream cannot be read again as - /// it is not rewindable for obvious reasons. This functionality is by design. - /// - /// The request. - /// - /// A task with the rest of the stream as a string, from the current position to the end. - /// If the current position is at the end of the stream, returns an empty string. - /// - public static async Task RequestBodyAsync(this IHttpRequest request) - { - if (!request.HasEntityBody) - return null; - - using (var body = request.InputStream) // here we have data - { - using (var reader = new StreamReader(body, request.ContentEncoding)) - { - return await reader.ReadToEndAsync().ConfigureAwait(false); - } - } - } - - /// - /// Requests the wildcard URL parameters. - /// - /// The context. - /// The base path. - /// The params from the request. - [Obsolete("Wilcard routing will be dropped in future versions")] - public static string[] RequestWildcardUrlParams(this IHttpContext context, string basePath) - => RequestWildcardUrlParams(context.RequestPath(), basePath); - - /// - /// Requests the wildcard URL parameters. - /// - /// The request path. - /// The base path. - /// The params from the request. - [Obsolete("Wilcard routing will be dropped in future versions")] - public static string[] RequestWildcardUrlParams(this string requestPath, string basePath) - { - var match = RegexCache.MatchWildcardStrategy(basePath, requestPath); - - return match.Success - ? match.Groups[1].Value.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) - : null; - } - - /// - /// Requests the regex URL parameters. - /// - /// The context. - /// The url pattern. - /// The params from the request. - [Obsolete("RequestRegexUrlParams() will be replaced for a new Routing class")] - public static Dictionary RequestRegexUrlParams(this IWebSocketContext context, string urlPattern) - => RequestRegexUrlParams(context.RequestUri.LocalPath, urlPattern); - - /// - /// Requests the regex URL parameters. - /// - /// The context. - /// The base path. - /// The params from the request. - [Obsolete("RequestRegexUrlParams() will be replaced for a new Routing class")] - public static Dictionary RequestRegexUrlParams(this IHttpContext context, - string basePath) - => RequestRegexUrlParams(context.RequestPath(), basePath); - - /// - /// Requests the regex URL parameters. - /// - /// The request path. - /// The base path. - /// The validate function. - /// - /// The params from the request. - /// - [Obsolete("RequestRegexUrlParams() will be replaced for a new Routing class")] - public static Dictionary RequestRegexUrlParams( - this string requestPath, - string basePath, - Func validateFunc = null) - { - if (validateFunc == null) validateFunc = () => false; - if (requestPath == basePath && !validateFunc()) return new Dictionary(); - - var i = 1; // match group index - var match = RegexCache.MatchRegexStrategy(basePath, requestPath); - var pathParts = basePath.Split('/'); - - if (match.Success && !validateFunc()) - { - return pathParts - .Where(x => x.StartsWith("{")) - .ToDictionary(CleanParamId, x => (object)match.Groups[i++].Value); - } - - var optionalPath = RouteOptionalParamRegex.Replace(basePath, string.Empty); - var tempPath = requestPath; - - if (optionalPath.Last() == '/' && requestPath.Last() != '/') - { - tempPath += "/"; - } - - var subMatch = RegexCache.MatchRegexStrategy(optionalPath, tempPath); - - if (!subMatch.Success || validateFunc()) return null; - - var valuesPaths = optionalPath.Split('/') - .Where(x => x.StartsWith("{")) - .ToDictionary(CleanParamId, x => (object)subMatch.Groups[i++].Value); - - var nullPaths = pathParts - .Where(x => x.StartsWith("{")) - .Select(CleanParamId); - - foreach (var nullKey in nullPaths) - { - if (!valuesPaths.ContainsKey(nullKey)) - valuesPaths.Add(nullKey, null); - } - - return valuesPaths; - } - - /// - /// Parses the JSON as a given type from the request body. - /// Please note the underlying input stream is not rewindable. - /// - /// The type of specified object type. - /// The context. - /// - /// A task with the JSON as a given type from the request body. - /// - public static async Task ParseJsonAsync(this IHttpContext context) - where T : class - { - var requestBody = await context.RequestBodyAsync().ConfigureAwait(false); - return requestBody == null ? null : Json.Deserialize(requestBody); - } - - /// - /// Transforms the response body as JSON and write a new JSON to the request. - /// - /// The type of the input. - /// The type of the output. - /// The context. - /// The transform function. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static async Task TransformJson( - this IHttpContext context, - Func> transformFunc, - CancellationToken cancellationToken = default) - where TIn : class - { - var requestJson = await context.ParseJsonAsync() - .ConfigureAwait(false); - var responseJson = await transformFunc(requestJson, cancellationToken) - .ConfigureAwait(false); - - return await context.JsonResponseAsync(responseJson, cancellationToken) - .ConfigureAwait(false); - } - - /// - /// Transforms the response body as JSON and write a new JSON to the request. - /// - /// The type of the input. - /// The type of the output. - /// The context. - /// The transform function. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - public static async Task TransformJson( - this IHttpContext context, - Func transformFunc, - CancellationToken cancellationToken = default) - where TIn : class - { - var requestJson = await context.ParseJsonAsync() - .ConfigureAwait(false); - var responseJson = transformFunc(requestJson); - - return await context.JsonResponseAsync(responseJson, cancellationToken) - .ConfigureAwait(false); - } - - /// - /// Check if the Http Request can be gzipped (ignore audio and video content type). - /// - /// The context. - /// The length. - /// true if a request can be gzipped; otherwise, false. - public static bool AcceptGzip(this IHttpContext context, long length) => - context.RequestHeader(HttpHeaderNames.AcceptEncoding).Contains(HttpHeaders.CompressionGzip) && - length < Modules.FileModuleBase.MaxGzipInputLength && - context.Response.ContentType?.StartsWith("audio") != true && - context.Response.ContentType?.StartsWith("video") != true; - - /// - /// 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 StandardResponseWithoutBody(this IHttpContext @this, int statusCode) - => @this.Response.StandardResponseWithoutBody(statusCode); - - /// - /// 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 used to cancel the operation. - /// A representing the ongoing operation. - /// is . - /// There is no standard status description for . - public static Task StandardHtmlResponseAsync(this IHttpContext @this, int statusCode, CancellationToken cancellationToken) - => StandardHtmlResponseAsync(@this, statusCode, null, cancellationToken); - - /// - /// 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 append additional HTML code - /// to the response. If not , the callback is called immediately before - /// closing the HTML body tag. - /// A used to cancel the operation. - /// A representing the ongoing operation. - /// is . - /// There is no standard status description for . - public static Task StandardHtmlResponseAsync( - this IHttpContext @this, - int statusCode, - Func appendAdditionalHtml, - CancellationToken cancellationToken) - => @this.Response.StandardHtmlResponseAsync(statusCode, appendAdditionalHtml, cancellationToken); - - /// - /// 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. - /// - [Obsolete("This method will change signature to: void Redirect(this IHttpContext @this, string location, int statusCode = (int)HttpStatusCode.Found)")] - public static void Redirect(this IHttpContext @this, string location, int statusCode) - { - location = ValidateUrl(nameof(location), location, @this.Request.Url); - - if (statusCode < 300 || statusCode > 399) - throw new ArgumentException("Redirect status code is not valid.", nameof(statusCode)); - - @this.Response.Headers[HttpHeaders.Location] = location; - @this.Response.StandardResponseWithoutBody(statusCode); - } - - /// - /// Sets a response static code of 302 and adds a Location header to the response - /// in order to direct the client to a different URL. - /// - /// The context. - /// The location. - /// if set to true [use absolute URL]. - /// true if the headers were set, otherwise false. - [Obsolete("This method will change signature to: void Redirect(this IHttpContext @this, string location, int statusCode = (int)HttpStatusCode.Found)")] - public static bool Redirect(this IHttpContext context, string location, bool useAbsoluteUrl = true) - { - if (useAbsoluteUrl) - { - var hostPath = context.Request.Url.GetComponents(UriComponents.Scheme | UriComponents.StrongAuthority, - UriFormat.Unescaped); - location = hostPath + location; - } - - context.Redirect(location, (int) HttpStatusCode.Found); - - return true; - } - - #endregion - - #region Data Parsing Methods - - /// - /// Returns a dictionary of KVPs from Request data. - /// - /// The request body. - /// A collection that represents KVPs from request data. - public static Dictionary RequestFormDataDictionary(this string requestBody) - => FormDataParser.ParseAsDictionary(requestBody); - - /// - /// Returns dictionary from Request POST data - /// Please note the underlying input stream is not rewindable. - /// - /// The context to request body as string. - /// A task with a collection that represents KVPs from request data. - public static async Task> RequestFormDataDictionaryAsync(this IHttpContext context) - => RequestFormDataDictionary(await context.RequestBodyAsync().ConfigureAwait(false)); - - #endregion - - #region Hashing and Compression Methods - - /// - /// Compresses the specified buffer stream using the G-Zip compression algorithm. - /// - /// The buffer. - /// The method. - /// The mode. - /// The cancellation token. - /// - /// A task representing the block of bytes of compressed stream. - /// - public static async Task CompressAsync( - this Stream buffer, - CompressionMethod method = CompressionMethod.Gzip, - CompressionMode mode = CompressionMode.Compress, - CancellationToken cancellationToken = default) - { - buffer.Position = 0; - var targetStream = new MemoryStream(); - - switch (method) - { - case CompressionMethod.Deflate: - if (mode == CompressionMode.Compress) - { - using (var compressor = new DeflateStream(targetStream, CompressionMode.Compress, true)) - { - await buffer.CopyToAsync(compressor, 1024, cancellationToken).ConfigureAwait(false); - await buffer.CopyToAsync(compressor).ConfigureAwait(false); - - // WebSocket use this - targetStream.Write(LastByte, 0, 1); - targetStream.Position = 0; - } - } - else - { - using (var compressor = new DeflateStream(buffer, CompressionMode.Decompress)) - { - await compressor.CopyToAsync(targetStream).ConfigureAwait(false); - } - } - - break; - case CompressionMethod.Gzip: - if (mode == CompressionMode.Compress) - { - using (var compressor = new GZipStream(targetStream, CompressionMode.Compress, true)) - { - await buffer.CopyToAsync(compressor).ConfigureAwait(false); - } - } - else - { - using (var compressor = new GZipStream(buffer, CompressionMode.Decompress)) - { - await compressor.CopyToAsync(targetStream).ConfigureAwait(false); - } - } - - break; - case CompressionMethod.None: - await buffer.CopyToAsync(targetStream).ConfigureAwait(false); - break; - default: - throw new ArgumentOutOfRangeException(nameof(method), method, null); - } - - return targetStream; - } - - #endregion - - internal static string CleanParamId(string val) => val.ReplaceAll(string.Empty, '{', '}', '?'); - - internal static Uri ToUri(this string uriString) - { - Uri.TryCreate( - uriString, uriString.MaybeUri() ? UriKind.Absolute : UriKind.Relative, out var ret); - - return ret; - } - - internal static bool MaybeUri(this string value) - { - var idx = value?.IndexOf(':'); - - if (!idx.HasValue || idx == -1) - return false; - - return idx < 10 && value.Substring(0, idx.Value).IsPredefinedScheme(); - } - - internal static bool IsPredefinedScheme(this string value) => value != null && - (value == "http" || value == "https" || value == "ws" || value == "wss"); - - internal static T NotNull(string argumentName, T value) - where T : class - => value ?? throw new ArgumentNullException(argumentName); - - internal static string ValidateUrl(string argumentName, string value, Uri baseUri, bool enforceHttp = false) - { - if (!NotNull(nameof(baseUri), baseUri).IsAbsoluteUri) - throw new ArgumentException("Base URI is not an absolute URI.", nameof(baseUri)); - - Uri uri; - try - { - uri = new Uri(baseUri, new Uri(NotNull(argumentName, value), UriKind.RelativeOrAbsolute)); - } - catch (UriFormatException e) - { - throw new ArgumentException("URL is not valid.", argumentName, e); - } - - if (enforceHttp && uri.IsAbsoluteUri && uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - throw new ArgumentException("URL scheme is neither HTTP nor HTTPS.", argumentName); - - return uri.ToString(); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/HttpContext.cs b/src/Unosquare.Labs.EmbedIO/HttpContext.cs deleted file mode 100644 index f4e4e74ba..000000000 --- a/src/Unosquare.Labs.EmbedIO/HttpContext.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using System.Collections.Generic; - using System.Net; - using System.Security.Principal; - using System.Threading.Tasks; - - /// - /// Represents a wrapper around a regular HttpListenerContext. - /// - /// - public class HttpContext : IHttpContext - { - private readonly HttpListenerContext _context; - private Lazy> _items = - new Lazy>(() => new Dictionary(), true); - - /// - /// Initializes a new instance of the class. - /// - /// The context. - public HttpContext(HttpListenerContext context) - { - _context = context; - Request = new HttpRequest(_context); - User = _context.User; - Response = new HttpResponse(_context); - } - - /// - public IHttpRequest Request { get; } - - /// - public IHttpResponse Response { get; } - - /// - public IPrincipal User { get; } - - /// - public IWebServer WebServer { get; set; } - - /// - public IDictionary Items - { - get => _items.Value; - set => _items = new Lazy>(() => value, true); - } - - /// - public async Task AcceptWebSocketAsync(int receiveBufferSize, string subProtocol = null) - => new WebSocketContext(await _context.AcceptWebSocketAsync(subProtocol, - receiveBufferSize, - TimeSpan.FromSeconds(30)) - .ConfigureAwait(false)); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/HttpHandler.cs b/src/Unosquare.Labs.EmbedIO/HttpHandler.cs deleted file mode 100644 index a14dc87b9..000000000 --- a/src/Unosquare.Labs.EmbedIO/HttpHandler.cs +++ /dev/null @@ -1,167 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using Swan; - using System; - using System.Linq; - using System.Net; - using System.Reflection; - using System.Threading; - using System.Threading.Tasks; - - internal class HttpHandler - { - private readonly IHttpContext _context; - private string _requestId = "(not set)"; - - public HttpHandler(IHttpContext context) - { - _context = context; - } - - /// - /// Handles the client request. - /// - /// The cancellation token. - /// A task that represents the asynchronous of client request. - public async Task HandleClientRequest(CancellationToken ct) - { - try - { - // Create a request endpoint string - var requestEndpoint = - $"{_context.Request?.RemoteEndPoint?.Address}:{_context.Request?.RemoteEndPoint?.Port}"; - - // Generate a random request ID. It's currently not important but could be useful in the future. - _requestId = string.Concat(DateTime.Now.Ticks.ToString(), requestEndpoint).GetHashCode().ToString("x2"); - - // Log the request and its ID - $"Start of Request {_requestId} - Source {requestEndpoint} - {_context.RequestVerb().ToString().ToUpperInvariant()}: {_context.Request.Url.PathAndQuery} - {_context.Request.UserAgent}" - .Debug(nameof(HttpHandler)); - - var processResult = await ProcessRequest(ct).ConfigureAwait(false); - - // Return a 404 (Not Found) response if no module/handler handled the response. - if (processResult == false) - { - "No module generated a response. Sending 404 - Not Found".Error(nameof(HttpHandler)); - - if (_context.WebServer.OnNotFound == null) - { - _context.Response.StandardResponseWithoutBody((int) HttpStatusCode.NotFound); - } - else - { - await _context.WebServer.OnNotFound(_context).ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - ex.Log(nameof(HttpHandler), "Error handling request."); - } - finally - { - // Always close the response stream no matter what. - _context?.Response.Close(); - - $"End of Request {_requestId}".Debug(nameof(HttpHandler)); - } - } - - private async Task ProcessRequest(CancellationToken ct) - { - // Iterate though the loaded modules to match up a request and possibly generate a response. - foreach (var module in _context.WebServer.Modules) - { - var callback = GetHandler(module); - - if (callback == null) continue; - - try - { - // Log the module and handler to be called and invoke as a callback. - $"{module.Name}::{callback.GetMethodInfo().DeclaringType?.Name}.{callback.GetMethodInfo().Name}" - .Debug(nameof(HttpHandler)); - - // Execute the callback - var handleResult = await callback(_context, ct).ConfigureAwait(false); - - $"Result: {handleResult}".Trace(nameof(HttpHandler)); - - // callbacks can instruct the server to stop bubbling the request through the rest of the modules by returning true; - if (handleResult) - { - return true; - } - } - catch (Exception ex) - { - // Handle exceptions by returning a 500 (Internal Server Error) - if (_context.Response.StatusCode != (int) HttpStatusCode.Unauthorized) - { - await ResponseServerError(ct, ex).ConfigureAwait(false); - } - - // Finally set the handled flag to true and exit. - return true; - } - } - - return false; - } - - private async Task ResponseServerError(CancellationToken cancellationToken, Exception ex) - { - if (_context.WebServer.UnhandledException != null && await _context.WebServer.UnhandledException.Invoke(_context, ex, cancellationToken)) - return; - - // Send the response over with the corresponding status code. - await _context.Response.StandardHtmlResponseAsync( - (int) HttpStatusCode.InternalServerError, - sb => sb - .Append("

Message

")
-                    .Append(ex.ExceptionMessage())
-                    .Append("

Stack Trace

\r\n")
-                    .Append(ex.StackTrace)
-                    .Append("
"), - cancellationToken).ConfigureAwait(false); - } - - private Map GetHandlerFromRegexPath(IWebModule module) - => module.Handlers.FirstOrDefault(x => - (x.Path == ModuleMap.AnyPath || _context.RequestRegexUrlParams(x.Path) != null) && - (x.Verb == HttpVerbs.Any || x.Verb == _context.RequestVerb())); - - private Map GetHandlerFromWildcardPath(IWebModule module) - { - var path = _context.RequestWilcardPath(module.Handlers - .Where(k => k.Path.Contains(ModuleMap.AnyPathRoute)) - .Select(s => s.Path.ToLowerInvariant())); - - return module.Handlers - .FirstOrDefault(x => - (x.Path == ModuleMap.AnyPath || x.Path == path) && - (x.Verb == HttpVerbs.Any || x.Verb == _context.RequestVerb())); - } - - private WebHandler GetHandler(IWebModule module) - { - Map handler; - - switch (_context.WebServer.RoutingStrategy) - { - case RoutingStrategy.Wildcard: - handler = GetHandlerFromWildcardPath(module); - break; - case RoutingStrategy.Regex: - handler = GetHandlerFromRegexPath(module); - break; - default: - throw new ArgumentOutOfRangeException(nameof(RoutingStrategy)); - } - - return handler?.ResponseHandler; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/HttpListener.cs b/src/Unosquare.Labs.EmbedIO/HttpListener.cs deleted file mode 100644 index 606802580..000000000 --- a/src/Unosquare.Labs.EmbedIO/HttpListener.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a wrapper for Microsoft HTTP Listener. - /// - internal class HttpListener : IHttpListener - { - private readonly System.Net.HttpListener _httpListener; - - public HttpListener(System.Net.HttpListener httpListener) - { - _httpListener = httpListener; - } - - /// - public bool IgnoreWriteExceptions - { - get => _httpListener.IgnoreWriteExceptions; - set => _httpListener.IgnoreWriteExceptions = value; - } - - /// - public List Prefixes => _httpListener.Prefixes.Select(y => y.ToString()).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 ct) - => new HttpContext(await _httpListener.GetContextAsync().ConfigureAwait(false)); - - void IDisposable.Dispose() - => ((IDisposable)_httpListener)?.Dispose(); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/HttpListenerFactory.cs b/src/Unosquare.Labs.EmbedIO/HttpListenerFactory.cs deleted file mode 100644 index f87c36eb4..000000000 --- a/src/Unosquare.Labs.EmbedIO/HttpListenerFactory.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using System.Security.Cryptography.X509Certificates; - - /// - /// Represents a Factory to create a HTTP Listener. - /// - public static class HttpListenerFactory - { - /// - /// Creates this instance with the default mode. - /// The default HTTP Listener is Microsoft for netstandard2.0 target frameworks, otherwise EmbedIO. - /// - /// The certificate. - /// - /// A HTTP Listener. - /// - public static IHttpListener Create(X509Certificate certificate = null) => Create(HttpListenerMode.Microsoft, certificate); - - /// - /// Creates the specified mode. - /// - /// The mode. - /// The certificate. - /// - /// A HTTP Listener. - /// - /// mode - null. - public static IHttpListener Create(HttpListenerMode mode, X509Certificate certificate = null) - { - switch (mode) - { - case HttpListenerMode.EmbedIO: - return new Net.HttpListener(certificate); - case HttpListenerMode.Microsoft: - if (System.Net.HttpListener.IsSupported) - return new HttpListener(new System.Net.HttpListener()); - - return new Net.HttpListener(certificate); - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid HTTP Listener mode."); - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/HttpListenerMode.cs b/src/Unosquare.Labs.EmbedIO/HttpListenerMode.cs deleted file mode 100644 index e9d6ca223..000000000 --- a/src/Unosquare.Labs.EmbedIO/HttpListenerMode.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - /// - /// Enums all the HTTP listener available. - /// - public enum HttpListenerMode - { - /// - /// The EmbedIO mode - /// - EmbedIO, - - /// - /// The Microsoft mode - /// - Microsoft, - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/MethodCache.cs b/src/Unosquare.Labs.EmbedIO/MethodCache.cs deleted file mode 100644 index 97ba9d72c..000000000 --- a/src/Unosquare.Labs.EmbedIO/MethodCache.cs +++ /dev/null @@ -1,146 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Modules; - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Linq; - using System.Linq.Expressions; - using System.Reflection; - using System.Threading.Tasks; - - internal class MethodCache - { - public MethodCache(MethodInfo methodInfo) - { - var type = methodInfo?.DeclaringType ?? throw new ArgumentNullException(nameof(methodInfo)); - - MethodInfo = methodInfo; - ControllerName = type.FullName; - SetHeadersInvoke = ctrl => ctrl.SetDefaultHeaders(); - IsTask = methodInfo.ReturnType == typeof(Task); - AdditionalParameters = methodInfo.GetParameters() - .Select(x => new AdditionalParameterInfo(x)) - .ToList(); - - var invokeDelegate = BuildDelegate(methodInfo, IsTask, type); - - if (IsTask) - AsyncInvoke = (AsyncDelegate) invokeDelegate; - else - SyncInvoke = (SyncDelegate) invokeDelegate; - } - - public delegate Task AsyncDelegate(object instance, object[] arguments); - - public delegate bool SyncDelegate(object instance, object[] arguments); - - public MethodInfo MethodInfo { get; } - public Action SetHeadersInvoke { get; } - public bool IsTask { get; } - public List AdditionalParameters { get; } - public string ControllerName { get; } - public AsyncDelegate AsyncInvoke { get; } - public SyncDelegate SyncInvoke { get; } - - private static Delegate BuildDelegate(MethodInfo methodInfo, bool isAsync, Type type) - { - var instanceExpression = Expression.Parameter(typeof(object), "instance"); - var argumentsExpression = Expression.Parameter(typeof(object[]), "arguments"); - - var argumentExpressions = methodInfo.GetParameters() - .Select( - (parameterInfo, i) => - Expression.Convert(Expression.ArrayIndex(argumentsExpression, Expression.Constant(i)), - parameterInfo.ParameterType)) - .Cast() - .ToList(); - - var callExpression = Expression.Call( - Expression.Convert(instanceExpression, type), - methodInfo, - argumentExpressions); - - if (isAsync) - { - return Expression.Lambda( - Expression.Convert(callExpression, typeof(Task)), - instanceExpression, - argumentsExpression) - .Compile(); - } - - return Expression.Lambda( - Expression.Convert(callExpression, typeof(bool)), - instanceExpression, - argumentsExpression) - .Compile(); - } - } - - internal class MethodCacheInstance - { - private readonly Func _controllerFactory; - - public MethodCacheInstance(Func controllerFactory, MethodCache cache) - { - _controllerFactory = controllerFactory; - MethodCache = cache; - } - - public MethodCache MethodCache { get; } - - public void ParseArguments(Dictionary parameters, object[] arguments) - { - // Parse the arguments to their intended type skipping the first two. - for (var i = 0; i < MethodCache.AdditionalParameters.Count; i++) - { - var param = MethodCache.AdditionalParameters[i]; - - // convert and add to arguments, if null use default value - arguments[i] = parameters.ContainsKey(param.Info.Name) - ? param.GetValue((string) parameters[param.Info.Name]) - : param.Default; - } - } - - public Task Invoke(WebApiController controller, object[] arguments) => - MethodCache.IsTask - ? MethodCache.AsyncInvoke(controller, arguments) - : Task.FromResult(MethodCache.SyncInvoke(controller, arguments)); - - public WebApiController SetDefaultHeaders(IHttpContext context) - { - var controller = _controllerFactory(context) as WebApiController; - MethodCache.SetHeadersInvoke(controller); - - return controller; - } - } - - internal class AdditionalParameterInfo - { - private readonly TypeConverter _converter; - - public AdditionalParameterInfo(ParameterInfo parameterInfo) - { - Info = parameterInfo; - _converter = TypeDescriptor.GetConverter(parameterInfo.ParameterType); - - if (parameterInfo.ParameterType.GetTypeInfo().IsValueType) - Default = Activator.CreateInstance(parameterInfo.ParameterType); - } - - public object Default { get; } - public ParameterInfo Info { get; } - - public object GetValue(string value) - { - if (string.IsNullOrWhiteSpace(value)) - value = null; // ignore whitespace - - // convert and add to arguments, if null use default value - return value == null ? Default : _converter.ConvertFromString(value); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/ModuleMap.cs b/src/Unosquare.Labs.EmbedIO/ModuleMap.cs deleted file mode 100644 index 4d0ff34f6..000000000 --- a/src/Unosquare.Labs.EmbedIO/ModuleMap.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - using Constants; - using System.Collections.Generic; - - /// - /// - /// Represents a list which binds Paths and their corresponding HTTP Verbs to Method calls. - /// - [Obsolete("ModuleMap will be dropped in future versions")] - public class ModuleMap : List - { - /// - /// Defines the path used to bind to all paths. - /// - public const string AnyPath = "*"; - - internal const string AnyPathRoute = "/*"; - } - - /// - /// Represents a binding of path and verb to a given method call (delegate). - /// - [Obsolete("Map will be dropped in future versions")] - public class Map - { - /// - /// The HTTP resource path. - /// - public string Path { get; set; } - - /// - /// The HTTP Verb of this Map. - /// - public HttpVerbs Verb { get; set; } - - /// - /// The delegate to call for the given path and verb. - /// - public WebHandler ResponseHandler { get; set; } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/ActionModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/ActionModule.cs deleted file mode 100644 index 7fa4c13cd..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/ActionModule.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - using Constants; - - /// - /// A module that passes requests to a callback. - /// - public class ActionModule - : WebModuleBase - { - /// - /// Initializes a new instance of the class. - /// - /// The URL. - /// The HTTP verb that will be served by this module. - /// The callback used to handle requests. - /// is . - public ActionModule(string url, HttpVerbs verb, WebHandler handler) - { - AddHandler(url, verb, handler); - } - - /// - /// Initializes a new instance of the class. - /// - /// The handler. - public ActionModule(WebHandler handler) - : this(ModuleMap.AnyPath, HttpVerbs.Any, handler) { } - - /// - public override string Name => nameof(ActionModule); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/AuthModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/AuthModule.cs deleted file mode 100644 index 89ed54979..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/AuthModule.cs +++ /dev/null @@ -1,119 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using System; - using System.Text; - using System.Threading.Tasks; - using System.Collections.Generic; - using System.Collections.Concurrent; - - /// - /// Simple authorization module that requests http auth from client - /// will return 401 + WWW-Authenticate header if request isn't authorized. - /// - public class AuthModule : WebModuleBase - { - private readonly ConcurrentDictionary _accounts = new ConcurrentDictionary(); - - /// - /// Initializes a new instance of the class. - /// - /// The username. - /// The password. - public AuthModule(string username, string password) - : this() - { - AddAccount(username, password); - } - - /// - /// Initializes a new instance of the class. - /// - public AuthModule() - { - AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (context, ct) => - { - try - { - if (!IsAuthorized(context.Request)) - context.Response.StatusCode = 401; - } - catch (FormatException) - { - // Credentials were not formatted correctly. - context.Response.StatusCode = 401; - } - - if (context.Response.StatusCode != 401) return Task.FromResult(false); - - context.Response.AddHeader("WWW-Authenticate", "Basic realm=\"Realm\""); - - return Task.FromResult(true); - }); - } - - /// - public override string Name => nameof(AuthModule); - - /// - /// Validates request and returns true if that account data registered in this module and request has auth data. - /// - /// The HTTP Request. - /// - /// true if request authorized, otherwise false. - /// - public bool IsAuthorized(IHttpRequest request) - { - try - { - var data = GetAccountData(request); - - if (!_accounts.TryGetValue(data.Key, out var password) || password != data.Value) - return false; - } - catch - { - return false; - } - - return true; - } - - /// - /// Add new account. - /// - /// account username. - /// account password. - public void AddAccount(string username, string password) => _accounts.TryAdd(username, password); - - /// - /// Parses request for account data. - /// - /// The HTTP Request. - /// user-password KeyValuePair from request. - /// - /// if request isn't authorized. - /// - private static KeyValuePair GetAccountData(IHttpBase request) - { - var authHeader = request.Headers["Authorization"]; - if (authHeader == null) - throw new ArgumentException("Authorization header not found"); - - var authHeaderParts = authHeader.Split(' '); - - // RFC 2617 sec 1.2, "scheme" name is case-insensitive - // header contains name and parameter separated by space. If it equals just "basic" - it's empty - if (!authHeaderParts[0].Equals("basic", StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException("Authorization header not found"); - - var credentials = Encoding.GetEncoding("iso-8859-1").GetString(Convert.FromBase64String(authHeaderParts[1])); - - var separator = credentials.IndexOf(':'); - var name = credentials.Substring(0, separator); - var password = credentials.Substring(separator + 1); - - return new KeyValuePair(name, password); - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs deleted file mode 100644 index c19e58378..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using System.Threading.Tasks; - using System; - using System.Linq; - using System.Collections.Generic; - - /// - /// CORS control Module. - /// Cross-origin resource sharing (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 - { - private const string Wildcard = "*"; - - /// - /// Initializes a new instance of the class. - /// - /// The origins. - /// The headers. - /// The methods. - /// - /// origins - /// or - /// headers - /// or - /// methods. - /// - public CorsModule( - string origins = Strings.CorsWildcard, - string headers = Strings.CorsWildcard, - string methods = Strings.CorsWildcard) - { - if (origins == null) throw new ArgumentNullException(nameof(origins)); - if (headers == null) throw new ArgumentNullException(nameof(headers)); - if (methods == null) throw new ArgumentNullException(nameof(methods)); - - var validOrigins = - origins.ToLowerInvariant() - .Split(Strings.CommaSplitChar, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()); - var validMethods = - methods.ToLowerInvariant() - .Split(Strings.CommaSplitChar, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()); - - AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (context, ct) => - { - var isOptions = context.RequestVerb() == HttpVerbs.Options; - - // If we allow all we don't need to filter - if (origins == Strings.CorsWildcard && headers == Strings.CorsWildcard && - methods == Strings.CorsWildcard) - { - context.Response.AddHeader(HttpHeaderNames.AccessControlAllowOrigin, Wildcard); - var result = isOptions && ValidateHttpOptions(methods, context, validMethods); - - return Task.FromResult(result); - } - - var currentOrigin = context.RequestHeader(HttpHeaderNames.Origin); - - if (string.IsNullOrWhiteSpace(currentOrigin) && context.Request.IsLocal) - { - return Task.FromResult(false); - } - - if (origins == Strings.CorsWildcard) - { - return Task.FromResult(false); - } - - if (validOrigins.Contains(currentOrigin)) - { - context.Response.AddHeader(HttpHeaderNames.AccessControlAllowOrigin, currentOrigin); - - if (isOptions) - { - return Task.FromResult(ValidateHttpOptions(methods, context, validMethods)); - } - } - - return Task.FromResult(false); - }); - } - - /// - public override string Name => nameof(CorsModule); - - private static bool ValidateHttpOptions( - string methods, - IHttpContext context, - IEnumerable validMethods) - { - var currentMethod = context.RequestHeader(HttpHeaderNames.AccessControlRequestMethod); - var currentHeader = context.RequestHeader(HttpHeaderNames.AccessControlRequestHeaders); - - if (!string.IsNullOrWhiteSpace(currentHeader)) - { - // TODO: I need to remove headers out from AllowHeaders - context.Response.AddHeader(HttpHeaderNames.AccessControlAllowHeaders, currentHeader); - } - - if (string.IsNullOrWhiteSpace(currentMethod)) - return true; - - var currentMethods = currentMethod.ToLowerInvariant() - .Split(Strings.CommaSplitChar, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()); - - if (methods == Strings.CorsWildcard || currentMethods.All(validMethods.Contains)) - { - context.Response.AddHeader(HttpHeaderNames.AccessControlAllowMethods, currentMethod); - - return true; - } - - context.Response.StatusCode = (int) System.Net.HttpStatusCode.BadRequest; - - return false; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/FallbackModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/FallbackModule.cs deleted file mode 100644 index f78f696c3..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/FallbackModule.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using System.IO; - using System; - using System.Threading.Tasks; - - /// - /// Represents a module to fallback any request. - /// - /// - public class FallbackModule - : WebModuleBase - { - /// - /// Initializes a new instance of the class. - /// - /// The action. - /// The verb. - [Obsolete("FallbackModule will be replaced with ActionModule")] - public FallbackModule(WebHandler action, HttpVerbs verb = HttpVerbs.Any) - { - AddHandler( - ModuleMap.AnyPath, - verb, - action); - } - - /// - /// Initializes a new instance of the class. - /// - /// The redirect URL. - /// The verb. - /// redirectUrl. - [Obsolete("FallbackModule will be replaced with RedirectModule ")] - public FallbackModule(string redirectUrl, HttpVerbs verb = HttpVerbs.Any) - { - if (string.IsNullOrWhiteSpace(redirectUrl)) - throw new ArgumentNullException(nameof(redirectUrl)); - - RedirectUrl = redirectUrl; - - AddHandler( - ModuleMap.AnyPath, - verb, - (context, ct) => Task.FromResult(context.Redirect(redirectUrl))); - } - - /// - /// Initializes a new instance of the class. - /// - /// The file. - /// Type of the content. - /// The verb. - /// file. - [Obsolete("FallbackModule will be replaced with ActionModule")] - public FallbackModule(FileInfo file, string contentType = null, HttpVerbs verb = HttpVerbs.Any) - { - if (file == null) - throw new ArgumentNullException(nameof(file)); - - AddHandler( - ModuleMap.AnyPath, - verb, - (context, ct) => context.FileResponseAsync(file, contentType, true, ct)); - } - - /// - public override string Name => nameof(FallbackModule); - - /// - /// Gets the redirect URL. - /// - public string RedirectUrl { get; } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/FileModuleBase.cs b/src/Unosquare.Labs.EmbedIO/Modules/FileModuleBase.cs deleted file mode 100644 index dfcb91aa7..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/FileModuleBase.cs +++ /dev/null @@ -1,137 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Swan; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a files module base. - /// - /// - [Obsolete("This class will be replaced by FileModule")] - public abstract class FileModuleBase - : WebModuleBase - { - internal static readonly int MaxGzipInputLength = 4 * 1024 * 1024; - - internal static readonly int ChunkSize = 256 * 1024; - - /// - /// Gets a dictionary binding file extensions to MIME types. - /// - /// - /// The MIME type dictionary. - /// - public IDictionary MimeTypes { get; } = Constants.MimeTypes.DefaultMimeTypes.ToDictionary(x => x.Key, x => x.Value); - - /// - /// The default headers. - /// - public Dictionary DefaultHeaders { get; } = new Dictionary(); - - /// - /// Gets or sets a value indicating whether [use gzip]. - /// - /// - /// true if [use gzip]; otherwise, false. - /// - public bool UseGzip { get; set; } - - /// - /// Writes the file asynchronous. - /// - /// The partial header. - /// The response. - /// The buffer. - /// if set to true [use gzip]. - /// The ct. - /// - protected Task WriteFileAsync( - string partialHeader, - IHttpResponse response, - Stream buffer, - bool useGzip = true, - CancellationToken ct = default) - { - var fileSize = buffer.Length; - - // check if partial - if (!CalculateRange(partialHeader, fileSize, out var lowerByteIndex, out var upperByteIndex)) - return response.BinaryResponseAsync(buffer, UseGzip && useGzip, ct); - - if (upperByteIndex > fileSize) - { - // invalid partial request - response.StatusCode = 416; - response.ContentLength64 = 0; - response.AddHeader(HttpHeaderNames.ContentRange, $"bytes */{fileSize}"); - - return Task.Delay(0, ct); - } - - if (lowerByteIndex != 0 || upperByteIndex != fileSize) - { - response.StatusCode = 206; - response.ContentLength64 = upperByteIndex - lowerByteIndex + 1; - - response.AddHeader(HttpHeaderNames.ContentRange, - $"bytes {lowerByteIndex}-{upperByteIndex}/{fileSize}"); - } - - return response.WriteToOutputStream(buffer, lowerByteIndex, ct); - } - - /// - /// Sets the default cache headers. - /// - /// The response. - protected void SetDefaultCacheHeaders(IHttpResponse response) - { - response.AddHeader(HttpHeaderNames.CacheControl, - DefaultHeaders.GetValueOrDefault(HttpHeaderNames.CacheControl, "private")); - response.AddHeader(HttpHeaderNames.Pragma, DefaultHeaders.GetValueOrDefault(HttpHeaderNames.Pragma, string.Empty)); - response.AddHeader(HttpHeaderNames.Expires, DefaultHeaders.GetValueOrDefault(HttpHeaderNames.Expires, string.Empty)); - } - - /// - /// Sets the general headers. - /// - /// The response. - /// The UTC file date string. - /// The file extension. - protected void SetGeneralHeaders(IHttpResponse response, string utcFileDateString, string fileExtension) - { - if (!string.IsNullOrWhiteSpace(fileExtension) && MimeTypes.TryGetValue(fileExtension, out var mimeType)) - response.ContentType = mimeType; - - SetDefaultCacheHeaders(response); - - response.AddHeader(HttpHeaderNames.LastModified, utcFileDateString); - response.AddHeader(HttpHeaderNames.AcceptRanges, "bytes"); - } - - private static bool CalculateRange(string partialHeader, long fileSize, out long lowerByteIndex, out long upperByteIndex) - { - lowerByteIndex = 0; - upperByteIndex = fileSize - 1; - - if (string.IsNullOrWhiteSpace(partialHeader)) return false; - - try - { - var range = System.Net.Http.Headers.RangeHeaderValue.Parse(partialHeader).Ranges.First(); - lowerByteIndex = range.From ?? 0; - upperByteIndex = range.To ?? fileSize - 1; - return true; - } - catch - { - return false; - } - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/LocalSessionModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/LocalSessionModule.cs deleted file mode 100644 index 64b85010f..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/LocalSessionModule.cs +++ /dev/null @@ -1,197 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using EmbedIO; - using Swan; - using System; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - - /// - /// A simple module to handle in-memory sessions. Do not use for distributed applications. - /// - public class LocalSessionModule - : WebModuleBase, ISessionWebModule - { - /// - /// Defines the session cookie name. - /// - private const string SessionCookieName = "__session"; - - /// - /// The concurrent dictionary holding the sessions. - /// - private readonly ConcurrentDictionary _sessions = - new ConcurrentDictionary(Strings.StandardStringComparer); - - /// - /// Initializes a new instance of the class. - /// - public LocalSessionModule() - { - IsWatchdogEnabled = true; - - AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (context, ct) => - { - var requestSessionCookie = context.Request.Cookies[SessionCookieName]; - var isSessionRegistered = false; - - if (requestSessionCookie != null) - { - FixUpSessionCookie(context); - isSessionRegistered = _sessions.ContainsKey(requestSessionCookie.Value); - } - - if (requestSessionCookie == null) - { - // create the session if session not available on the request - var sessionCookie = CreateSession(); - context.Response.SetCookie(sessionCookie); - context.Request.Cookies.Add(sessionCookie); - $"Created session identifier '{sessionCookie.Value}'".Debug(nameof(LocalSessionModule)); - } - else if (isSessionRegistered == false) - { - // update session value - var sessionCookie = CreateSession(); - context.Response.SetCookie(sessionCookie); // = sessionCookie.Value; - context.Request.Cookies[SessionCookieName].Value = sessionCookie.Value; - $"Updated session identifier to '{sessionCookie.Value}'".Debug(nameof(LocalSessionModule)); - } - else - { - // If it does exist in the request, check if we're tracking it - var requestSessionId = context.Request.Cookies[SessionCookieName].Value; - _sessions[requestSessionId].LastActivity = DateTime.UtcNow; - $"Session Identified '{requestSessionId}'".Debug(nameof(LocalSessionModule)); - } - - // Always returns false because we need it to handle the rest for the modules - return Task.FromResult(false); - }); - } - - /// - public IReadOnlyDictionary Sessions => new Dictionary(_sessions); - - /// - /// Gets or sets the expiration. - /// By default, expiration is 30 minutes. - /// - /// - public TimeSpan Expiration { get; set; } = TimeSpan.FromMinutes(30); - - /// - /// Gets or sets the cookie path. - /// If left empty, a cookie will be created for each path. The default value is "/" - /// If a route is specified, then session cookies will be created only for the given path. - /// Examples of this are: - /// "/" - /// "/app1/". - /// - /// - /// The cookie path. - /// - public string CookiePath { get; set; } = "/"; - - /// - public override string Name => nameof(LocalSessionModule); - - /// - /// Gets the with the specified cookie value. - /// Returns null when the session is not found. - /// - /// - /// The . - /// - /// The cookie value. - /// Session info with the specified cookie value. - public SessionInfo this[string cookieValue] => _sessions.TryGetValue(cookieValue, out var value) ? value : null; - - /// - public override void RunWatchdog() - { - _sessions - .Select(x => x.Value) - .Where(x => x != null && DateTime.UtcNow.Subtract(x.LastActivity) > Expiration) - .ToList() - .ForEach(DeleteSession); - } - - /// - public SessionInfo GetSession(IHttpContext context) - { - if (context.Request.Cookies[SessionCookieName] == null) return null; - - var cookieValue = context.Request.Cookies[SessionCookieName].Value; - return this[cookieValue]; - } - - /// - public SessionInfo GetSession(IWebSocketContext context) - { - if (context.CookieCollection[SessionCookieName] == null) return null; - - var cookieValue = context.CookieCollection[SessionCookieName].Value; - return this[cookieValue]; - } - - /// - public void DeleteSession(IHttpContext context) => DeleteSession(GetSession(context)); - - /// - public void DeleteSession(SessionInfo session) - { - if (string.IsNullOrWhiteSpace(session?.SessionId) || !_sessions.ContainsKey(session.SessionId)) return; - _sessions.TryRemove(session.SessionId, out _); - } - - /// - /// Creates a session ID, registers the session info in the Sessions collection, and returns the appropriate session cookie. - /// - /// The sessions. - private System.Net.Cookie CreateSession() - { - var sessionId = Convert.ToBase64String( - System.Text.Encoding.UTF8.GetBytes( - Guid.NewGuid() + DateTime.UtcNow.Millisecond.ToString() + DateTime.UtcNow.Ticks)); - var sessionCookie = string.IsNullOrWhiteSpace(CookiePath) - ? new System.Net.Cookie(SessionCookieName, sessionId) - : new System.Net.Cookie(SessionCookieName, sessionId, CookiePath); - - _sessions[sessionId] = new SessionInfo(sessionId); - - return sessionCookie; - } - - /// - /// Fixes the session cookie to match the correct value. - /// System.Net.Cookie.Value only supports a single value and we need to pick the one that potentially exists. - /// - /// The context. - private void FixUpSessionCookie(IHttpContext context) - { - // get the real "__session" cookie value because sometimes there's more than 1 value and System.Net.Cookie only supports 1 value per cookie - if (context.Request.Headers[HttpHeaderNames.Cookie] == null) return; - - var cookieItems = context.Request.Headers[HttpHeaderNames.Cookie] - .Split(Strings.CookieSplitChars, StringSplitOptions.RemoveEmptyEntries); - - foreach (var cookieItem in cookieItems) - { - var nameValue = cookieItem.Trim().Split(new[] {'='}, StringSplitOptions.RemoveEmptyEntries); - - if (nameValue.Length != 2 || !nameValue[0].Equals(SessionCookieName)) continue; - - var sessionIdValue = nameValue[1].Trim(); - - if (!_sessions.ContainsKey(sessionIdValue)) continue; - - context.Request.Cookies[SessionCookieName].Value = sessionIdValue; - break; - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/RedirectModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/RedirectModule.cs deleted file mode 100644 index 89b7debc3..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/RedirectModule.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - using System.Net; - using System.Threading.Tasks; - - /// - /// A module that redirects requests. - /// - public class RedirectModule - : WebModuleBase - { - /// - /// Initializes a new instance of the class - /// that will redirect all served requests. - /// - /// The redirect URL. - /// The response status code; default is 302 - Found. - /// is . - /// - /// is not a valid URL. - /// - or - - /// - /// is not a redirection (3xx) status code. - /// - public RedirectModule(string redirectUrl, HttpStatusCode statusCode = HttpStatusCode.Found) - : this(ModuleMap.AnyPath, redirectUrl, null, statusCode, false) - { - } - - /// - /// Initializes a new instance of the class - /// that will redirect all requests for which the callback - /// returns . - /// - /// The URL. - /// The redirect URL. - /// A callback function that returns - /// if a request must be redirected. - /// The response status code; default is 302 - Found. - /// - /// is . - /// - or - - /// - /// is . - /// - /// is not a valid URL. - /// - or - - /// - /// is not a redirection (3xx) status code. - /// - public RedirectModule(string url, string redirectUrl, Func shouldRedirect, HttpStatusCode statusCode = HttpStatusCode.Found) - : this(url, redirectUrl, shouldRedirect, statusCode, true) - { - } - - private RedirectModule(string baseUrlPath, string redirectUrl, Func shouldRedirect, HttpStatusCode statusCode, bool useCallback) - { - RedirectUrl = ValidateUrl(nameof(redirectUrl), redirectUrl); - - var status = (int)statusCode; - if (status < 300 || status > 399) - throw new ArgumentException("Status code does not imply a redirection.", nameof(statusCode)); - - StatusCode = statusCode; - var shouldRedirect1 = useCallback ? Extensions.NotNull(nameof(shouldRedirect), shouldRedirect) : null; - - AddHandler( - baseUrlPath, - Constants.HttpVerbs.Any, - (context, ct) => - { - if (shouldRedirect1 != null && !shouldRedirect1(context, context.RequestPath())) - return Task.FromResult(false); - - context.Redirect(RedirectUrl, (int)StatusCode); - return Task.FromResult(true); - }); - } - - /// - /// Gets the redirect URL. - /// - public string RedirectUrl { get; } - - /// - /// Gets the response status code. - /// - public HttpStatusCode StatusCode { get; } - - /// - public override string Name => nameof(RedirectModule); - - private static string ValidateUrl( - string argumentName, - string value, - UriKind uriKind = UriKind.RelativeOrAbsolute) - { - Uri uri; - try - { - uri = new Uri(Extensions.NotNull(argumentName, value), uriKind); - } - catch (UriFormatException e) - { - throw new ArgumentException("URL is not valid.", argumentName, e); - } - - return uri.ToString(); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/ResourceFilesModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/ResourceFilesModule.cs deleted file mode 100644 index 31641c714..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/ResourceFilesModule.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using EmbedIO; - using Swan; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Reflection; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a simple module to server resource files from the .NET assembly. - /// - [Obsolete("This class will be replaced by FileModule")] - public class ResourceFilesModule - : FileModuleBase - { - private readonly Assembly _sourceAssembly; - private readonly string _resourcePathRoot; - - /// - /// Initializes a new instance of the class. - /// - /// The source assembly. - /// The resource path. - /// The headers. - /// sourceAssembly. - /// Path ' + fileSystemPath + ' does not exist. - public ResourceFilesModule( - Assembly sourceAssembly, - string resourcePath, - IDictionary headers = null) - { - if (sourceAssembly == null) - throw new ArgumentNullException(nameof(sourceAssembly)); - - if (sourceAssembly.GetName() == null) - throw new ArgumentException($"Assembly '{sourceAssembly}' is not valid."); - - UseGzip = true; - _sourceAssembly = sourceAssembly; - _resourcePathRoot = resourcePath; - - headers?.ForEach(DefaultHeaders.Add); - - AddHandler(ModuleMap.AnyPath, HttpVerbs.Head, (context, ct) => HandleGet(context, ct, false)); - AddHandler(ModuleMap.AnyPath, HttpVerbs.Get, (context, ct) => HandleGet(context, ct)); - } - - /// - public override string Name => nameof(ResourceFilesModule); - - private static string FixPath(string s) => s == "/" ? "index.html" : s.Substring(1, s.Length - 1).Replace('/', '.'); - - private async Task HandleGet(IHttpContext context, CancellationToken ct, bool sendBuffer = true) - { - Stream buffer = null; - - try - { - var localPath = FixPath(context.RequestPathCaseSensitive()); - var partialHeader = context.RequestHeader(HttpHeaderNames.Range); - - $"Resource System: {localPath}".Debug(nameof(ResourceFilesModule)); - - buffer = _sourceAssembly.GetManifestResourceStream($"{_resourcePathRoot}.{localPath}"); - - // If buffer is null something is really wrong - if (buffer == null) - { - return false; - } - - // check to see if the file was modified or e-tag is the same - var utcFileDateString = DateTime.Now.ToUniversalTime() - .ToString(Strings.BrowserTimeFormat, Strings.StandardCultureInfo); - - context.Response.ContentLength64 = buffer.Length; - - SetGeneralHeaders(context.Response, utcFileDateString, localPath.Contains(".") ? $".{localPath.Split('.').Last()}" : ".html"); - - if (sendBuffer) - { - await WriteFileAsync( - partialHeader, - context.Response, - buffer, - context.AcceptGzip(buffer.Length), - ct) - .ConfigureAwait(false); - } - } - catch (System.Net.HttpListenerException) - { - // Connection error, nothing else to do - } - catch (Net.HttpListenerException) - { - // Connection error, nothing else to do - } - finally - { - buffer?.Dispose(); - } - - return true; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs deleted file mode 100644 index 5e92941e1..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs +++ /dev/null @@ -1,461 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Core; - using Constants; - using EmbedIO; - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.Collections.Concurrent; - using System.Globalization; - using System.IO; - using System.Linq; - using Swan; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a simple module to server static files from the file system. - /// - [Obsolete("This class will be replaced by FileModule")] - public class StaticFilesModule : FileModuleBase, IDisposable - { - /// - /// Default document constant to "index.html". - /// - public const string DefaultDocumentName = VirtualPathManager.DefaultDocumentName; - - /// - /// Maximal length of entry in DirectoryBrowser. - /// - private const int MaxEntryLength = 50; - - /// - /// How many characters used after time in DirectoryBrowser. - /// - private const int SizeIndent = 20; - - private readonly VirtualPathManager _virtualPathManager; - - private readonly ConcurrentDictionary _fileHashCache = new ConcurrentDictionary(); - - /// - /// Initializes a new instance of the class. - /// - /// The paths. - [Obsolete("Virtual Paths will be dropped in future versions")] - public StaticFilesModule(Dictionary paths) - : this(paths.First().Value, null, paths) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The file system path. - /// if set to true [use directory browser]. - public StaticFilesModule(string fileSystemPath, bool useDirectoryBrowser) - : this(fileSystemPath, null, null, useDirectoryBrowser, true) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The file system path. - /// if set to true [use directory browser]. - /// if set to true, [cache mapped paths]. - public StaticFilesModule(string fileSystemPath, bool useDirectoryBrowser, bool cacheMappedPaths) - : this(fileSystemPath, null, null, useDirectoryBrowser, cacheMappedPaths) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The file system path. - /// The headers to set in every request. - /// The additional paths. - /// if set to true [use directory browser]. - /// Path ' + fileSystemPath + ' does not exist. - public StaticFilesModule( - string fileSystemPath, - Dictionary headers, - Dictionary additionalPaths, - bool useDirectoryBrowser) - : this(fileSystemPath, headers, additionalPaths, useDirectoryBrowser, true) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The file system path. - /// The headers to set in every request. - /// The additional paths. - /// if set to true [use directory browser]. - /// if set to true, [cache mapped paths]. - /// Path ' + fileSystemPath + ' does not exist. - public StaticFilesModule( - string fileSystemPath, - Dictionary headers = null, - Dictionary additionalPaths = null, - bool useDirectoryBrowser = false, - bool cacheMappedPaths = true) - { - if (!Directory.Exists(fileSystemPath)) - throw new ArgumentException($"Path '{fileSystemPath}' does not exist."); - - _virtualPathManager = new VirtualPathManager(Path.GetFullPath(fileSystemPath), useDirectoryBrowser, cacheMappedPaths); - - DefaultDocument = DefaultDocumentName; - UseGzip = true; -#if DEBUG - // When debugging, disable RamCache - UseRamCache = false; -#else - UseRamCache = true; -#endif - - headers?.ForEach(DefaultHeaders.Add); - additionalPaths?.ForEach((virtualPath, physicalPath) => - { - if (virtualPath != "/") - RegisterVirtualPath(virtualPath, physicalPath); - }); - - AddHandler(ModuleMap.AnyPath, HttpVerbs.Head, (context, ct) => HandleGet(context, ct, false)); - AddHandler(ModuleMap.AnyPath, HttpVerbs.Get, (context, ct) => HandleGet(context, ct)); - } - - /// - /// Finalizes an instance of the class. - /// - ~StaticFilesModule() - { - Dispose(false); - } - - /// - /// Gets or sets the maximum size of the ram cache file. The default value is 250kb. - /// - /// - /// The maximum size of the ram cache file. - /// - public int MaxRamCacheFileSize { get; set; } = 250 * 1024; - - /// - /// Gets or sets the default document. - /// Defaults to "index.html" - /// Example: "root.xml". - /// - /// - /// The default document. - /// - public string DefaultDocument - { - get => _virtualPathManager.DefaultDocument; - set => _virtualPathManager.DefaultDocument = value; - } - - /// - /// Gets or sets the default extension. - /// Defaults to null - /// Example: ".html". - /// - /// - /// The default extension. - /// - public string DefaultExtension - { - get => _virtualPathManager.DefaultExtension; - set => _virtualPathManager.DefaultExtension = value; - } - - /// - /// Gets the file system path from which files are retrieved. - /// - /// - /// The file system path. - /// - public string FileSystemPath => _virtualPathManager.RootLocalPath; - - /// - /// Gets or sets a value indicating whether or not to use the RAM Cache feature - /// RAM Cache will only cache files that are MaxRamCacheSize in bytes or less. - /// - /// - /// true if [use ram cache]; otherwise, false. - /// - public bool UseRamCache { get; set; } - - /// - /// Gets the virtual paths. - /// - /// - /// The virtual paths. - /// - [Obsolete("Virtual Paths will be dropped in future versions")] - public ReadOnlyDictionary VirtualPaths => _virtualPathManager.VirtualPaths; - - /// - public override string Name { get; } = nameof(StaticFilesModule); - - /// - /// Private collection holding the contents of the RAM Cache. - /// - /// - /// The ram cache. - /// - private RamCache RamCache { get; } = new RamCache(); - - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Registers the virtual path. - /// - /// The virtual path. - /// The physical path. - /// - /// Is thrown when a method call is invalid for the object's current state. - /// - [Obsolete("Virtual Paths will be dropped in future versions")] - public void RegisterVirtualPath(string virtualPath, string physicalPath) - => _virtualPathManager.RegisterVirtualPath(virtualPath, physicalPath); - - /// - /// Unregisters the virtual path. - /// - /// The virtual path. - /// - /// Is thrown when a method call is invalid for the object's current state. - /// - [Obsolete("Virtual Paths will be dropped in future versions")] - public void UnregisterVirtualPath(string virtualPath) => _virtualPathManager.UnregisterVirtualPath(virtualPath); - - /// - /// Clears the RAM cache. - /// - public void ClearRamCache() => RamCache.Clear(); - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; - - _virtualPathManager.Dispose(); - } - - private static Task HandleDirectory(IHttpContext context, string localPath, CancellationToken ct) - { - var entries = new[] { context.Request.RawUrl == "/" ? string.Empty : "../" } - .Concat( - Directory.GetDirectories(localPath) - .Select(path => - { - var name = path.Replace( - localPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, - string.Empty); - return new - { - Name = (name + Path.DirectorySeparatorChar).Truncate(MaxEntryLength, "..>"), - Url = Uri.EscapeDataString(name) + Path.DirectorySeparatorChar, - ModificationTime = new DirectoryInfo(path).LastWriteTimeUtc, - Size = "-", - }; - }) - .OrderBy(x => x.Name) - .Union(Directory.GetFiles(localPath, "*", SearchOption.TopDirectoryOnly) - .Select(path => - { - var fileInfo = new FileInfo(path); - var name = Path.GetFileName(path); - - return new - { - Name = name.Truncate(MaxEntryLength, "..>"), - Url = Uri.EscapeDataString(name), - ModificationTime = fileInfo.LastWriteTimeUtc, - Size = fileInfo.Length.FormatBytes(), - }; - }) - .OrderBy(x => x.Name)) - .Select(y => $"{System.Net.WebUtility.HtmlEncode(y.Name)}" + - new string(' ', MaxEntryLength - y.Name.Length + 1) + - y.ModificationTime.ToString(Strings.BrowserTimeFormat, - CultureInfo.InvariantCulture) + - new string(' ', SizeIndent - y.Size.Length) + - y.Size)) - .Where(x => !string.IsNullOrWhiteSpace(x)); - - var content = Responses.ResponseBaseHtml.Replace( - "{0}", - $"

Index of {System.Net.WebUtility.HtmlEncode(context.RequestPathCaseSensitive())}


{string.Join("\n", entries)}

"); - - return context.HtmlResponseAsync(content, cancellationToken: ct); - } - - private Task HandleGet(IHttpContext context, CancellationToken ct, bool sendBuffer = true) - { - switch (_virtualPathManager.MapUrlPath(context.RequestPathCaseSensitive(), out var localPath) & PathMappingResult.MappingMask) - { - case PathMappingResult.IsFile: - return HandleFile(context, localPath, sendBuffer, ct); - case PathMappingResult.IsDirectory: - return HandleDirectory(context, localPath, ct); - default: - return Task.FromResult(false); - } - } - - private async Task HandleFile( - IHttpContext context, - string localPath, - bool sendBuffer, - CancellationToken ct) - { - Stream buffer = null; - - try - { - var isTagValid = false; - var partialHeader = context.RequestHeader(HttpHeaderNames.Range); - var usingPartial = partialHeader?.StartsWith("bytes=") == true; - var fileInfo = new FileInfo(localPath); - - if (sendBuffer) - buffer = GetFileStream(context, fileInfo, usingPartial, out isTagValid); - - // check to see if the file was modified or e-tag is the same - var utcFileDateString = fileInfo.LastWriteTimeUtc - .ToString(Strings.BrowserTimeFormat, Strings.StandardCultureInfo); - - if (!usingPartial && - (isTagValid || context.RequestHeader(HttpHeaderNames.IfModifiedSince).Equals(utcFileDateString))) - { - SetStatusCode304(context.Response); - return true; - } - - context.Response.ContentLength64 = fileInfo.Length; - - SetGeneralHeaders(context.Response, utcFileDateString, fileInfo.Extension); - - if (!sendBuffer) - { - return true; - } - - // If buffer is null something is really wrong - if (buffer == null) - { - return false; - } - - await WriteFileAsync( - partialHeader, - context.Response, - buffer, - context.AcceptGzip(buffer.Length), - ct) - .ConfigureAwait(false); - } - catch (System.Net.HttpListenerException) - { - // Connection error, nothing else to do - } - catch (Net.HttpListenerException) - { - // Connection error, nothing else to do - } - finally - { - buffer?.Dispose(); - } - - return true; - } - - private Stream GetFileStream(IHttpContext context, FileSystemInfo fileInfo, bool usingPartial, out bool isTagValid) - { - isTagValid = false; - var localPath = fileInfo.FullName; - - if (UseRamCache && RamCache.IsValid(localPath, fileInfo.LastWriteTime, out var currentHash)) - { - isTagValid = context.RequestHeader(HttpHeaderNames.IfNoneMatch) == currentHash; - - if (isTagValid) - { - $"RAM Cache: {localPath}".Debug(nameof(StaticFilesModule)); - - context.Response.AddHeader(HttpHeaderNames.ETag, currentHash); - return new MemoryStream(RamCache[localPath].Buffer); - } - } - - $"File System: {localPath}".Debug(nameof(StaticFilesModule)); - - var buffer = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - if (usingPartial == false) - { - isTagValid = UpdateFileCache( - context.Response, - buffer, - fileInfo.LastWriteTime, - context.RequestHeader(HttpHeaderNames.IfNoneMatch), - localPath); - } - - return buffer; - } - - private bool UpdateFileCache( - IHttpResponse response, - Stream buffer, - DateTime fileDate, - string requestHash, - string localPath) - { - var currentHash = _fileHashCache.TryGetValue(localPath, out var currentTuple) && - fileDate.Ticks == currentTuple.DateTicks - ? currentTuple.HashCode - : $"{buffer.ComputeMD5().ToUpperHex()}-{fileDate.Ticks}"; - - _fileHashCache.TryAdd(localPath, (fileDate.Ticks, currentHash)); - - if (!string.IsNullOrWhiteSpace(requestHash) && requestHash == currentHash) - { - return true; - } - - if (UseRamCache && buffer.Length <= MaxRamCacheFileSize) - { - RamCache.Add(buffer, localPath, fileDate); - } - - response.AddHeader(HttpHeaderNames.ETag, currentHash); - - return false; - } - - private void SetStatusCode304(IHttpResponse response) - { - SetDefaultCacheHeaders(response); - - response.ContentType = string.Empty; - response.StatusCode = 304; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebApiController.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebApiController.cs deleted file mode 100644 index 623a0aba1..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebApiController.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Security.Principal; - - /// - /// Inherit from this class and define your own Web API methods - /// You must RegisterController in the Web API Module to make it active. - /// - public abstract class WebApiController - { - /// - /// Initializes a new instance of the class. - /// - /// The context. - protected WebApiController(IHttpContext context) - { - HttpContext = context; - } - - /// - /// Gets the HTTP context. - /// - /// - /// The HTTP context. - /// - public IHttpContext HttpContext { get; } - - /// - /// Gets the HTTP Request. - /// - /// - /// The request. - /// - public IHttpRequest Request => HttpContext.Request; - - /// - /// Gets the HTTP Response. - /// - /// - /// The response. - /// - public IHttpResponse Response => HttpContext.Response; - - /// - /// Gets the user. - /// - /// - /// The user. - /// - public IPrincipal User => HttpContext.User; - - /// - /// Gets or sets the web server. - /// - /// - /// The web server. - /// - public IWebServer WebServer => HttpContext.WebServer; - - /// - /// Sets the default headers to the Web API response. - /// By default will set: - /// - /// Expires - Mon, 26 Jul 1997 05:00:00 GMT - /// LastModified - (Current Date) - /// CacheControl - no-store, no-cache, must-revalidate - /// Pragma - no-cache - /// - /// Previous values are defined to avoid caching from client. - /// - [Obsolete("SetDefaultHeaders() will be replaced by OnBeforeHandler in future versions")] - public virtual void SetDefaultHeaders() => HttpContext.NoCache(); - - /// - /// Outputs async a Json Response given a data object. - /// - /// The data. - /// The cancellation token. - /// - /// A true value if the response output was set. - /// - protected virtual Task Ok(object data, CancellationToken cancellationToken = default) => - HttpContext.JsonResponseAsync(data, cancellationToken); - - /// - /// Transforms the response body as JSON and write a new JSON to the request. - /// - /// The type of the input. - /// The type of the output. - /// The transform function. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - protected virtual Task Ok(Func> transformFunc, - CancellationToken cancellationToken = default) - where TIn : class - => HttpContext.TransformJson(transformFunc, cancellationToken); - - /// - /// Outputs a JSON Response given an exception. - /// - /// The ex. - /// The status code. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - [Obsolete("InternalServerError() will be replaced by HttpResultException in future versions")] - protected virtual Task InternalServerError( - Exception ex, - System.Net.HttpStatusCode statusCode = System.Net.HttpStatusCode.InternalServerError, - bool useGzip = true, - CancellationToken cancellationToken = default) - => HttpContext.JsonExceptionResponseAsync(ex, statusCode, useGzip, cancellationToken); - - /// - /// Outputs async a string response given a string. - /// - /// The content. - /// Type of the content. - /// The encoding. - /// if set to true [use gzip]. - /// The cancellation token. - /// - /// A task for writing the output stream. - /// - protected virtual Task Ok( - string content, - string contentType = "application/json", - Encoding encoding = null, - bool useGzip = true, - CancellationToken cancellationToken = default) => - Response.StringResponseAsync(content, contentType, encoding, useGzip && HttpContext.AcceptGzip(content.Length), cancellationToken); - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebApiHandlerAttribute.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebApiHandlerAttribute.cs deleted file mode 100644 index 79f39dc61..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebApiHandlerAttribute.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - using Constants; - - /// - /// 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)] - public class WebApiHandlerAttribute : Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// The verb. - /// The paths. - /// The argument 'paths' must be specified. - public WebApiHandlerAttribute(HttpVerbs verb, string[] paths) - { - if (paths == null || paths.Length == 0) - { - throw new ArgumentException("The argument 'paths' must be specified."); - } - - Verb = verb; - Paths = paths; - } - - /// - /// Initializes a new instance of the class. - /// - /// The verb. - /// The path. - /// The argument 'path' must be specified. - public WebApiHandlerAttribute(HttpVerbs verb, string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("The argument 'path' must be specified."); - } - - Verb = verb; - Paths = new[] { path }; - } - - /// - /// Gets or sets the verb. - /// - /// - /// The verb. - /// - public HttpVerbs Verb { get; protected set; } - - /// - /// Gets or sets the paths. - /// - /// - /// The paths. - /// - public string[] Paths { get; protected set; } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebApiModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebApiModule.cs deleted file mode 100644 index 78aa43428..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebApiModule.cs +++ /dev/null @@ -1,272 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using EmbedIO; - using Swan; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using System.Threading.Tasks; - - /// - /// A very simple module to register class methods as handlers. - /// Public instance methods that match the WebServerModule.ResponseHandler signature, and have the WebApi handler attribute - /// will be used to respond to web server requests. - /// - public class WebApiModule - : WebModuleBase - { - private readonly List _controllerTypes = new List(); - - private readonly Dictionary> _delegateMap - = - new Dictionary>( - Strings.StandardStringComparer); - - /// - /// Initializes a new instance of the class. - /// - public WebApiModule() - : this(false) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// if set to true [response json exception]. - public WebApiModule(bool responseJsonException) - { - AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (context, ct) => TryHandleWebApi(context, responseJsonException)); - } - - /// - public override string Name { get; } = "Web API Module"; - - /// - /// Gets the number of controller objects registered in this API. - /// - public int ControllersCount => _controllerTypes.Count; - - /// - /// Registers the controller. - /// - /// The type of register controller. - /// Controller types must be unique within the module. - public void RegisterController() - where T : WebApiController - { - RegisterController(typeof(T)); - } - - /// - /// Registers the controller. - /// - /// The type of register controller. - /// The controller factory method. - /// Controller types must be unique within the module. - public void RegisterController(Func controllerFactory) - where T : WebApiController - { - RegisterController(typeof(T), controllerFactory); - } - - /// - /// Registers the controller. - /// - /// Type of the controller. - public void RegisterController(Type controllerType) - => RegisterController(controllerType, ctx => Activator.CreateInstance(controllerType, ctx)); - - /// - /// Registers the controller. - /// - /// Type of the controller. - /// The controller factory method. - public void RegisterController(Type controllerType, Func controllerFactory) - { - if (_controllerTypes.Contains(controllerType)) - throw new ArgumentException("Controller types must be unique within the module"); - - var methods = controllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public) - .Where(m => m.ReturnType == typeof(bool) - || m.ReturnType == typeof(Task)); - - foreach (var method in methods) - { - if (!(method.GetCustomAttributes(typeof(WebApiHandlerAttribute), true).FirstOrDefault() is WebApiHandlerAttribute attribute)) - continue; - - foreach (var path in attribute.Paths) - { - if (_delegateMap.ContainsKey(path) == false) - { - _delegateMap.Add(path, new Dictionary()); // add - } - - var delegatePair = new MethodCacheInstance(controllerFactory, new MethodCache(method)); - - if (_delegateMap[path].ContainsKey(attribute.Verb)) - _delegateMap[path][attribute.Verb] = delegatePair; // update - else - _delegateMap[path].Add(attribute.Verb, delegatePair); // add - } - } - - _controllerTypes.Add(controllerType); - } - - /// - /// Normalizes a path meant for Regex matching, extracts the route parameters, and returns the registered - /// path in the internal delegate map. - /// - /// The verb. - /// The context. - /// The route parameters. - /// A string that represents the registered path in the internal delegate map. - private string NormalizeRegexPath( - HttpVerbs verb, - IHttpContext context, - IDictionary routeParams) - { - var path = context.Request.Url.LocalPath; - - foreach (var route in _delegateMap.Keys) - { - var urlParam = path.RequestRegexUrlParams(route, () => !_delegateMap[route].Keys.Contains(verb)); - - if (urlParam == null) continue; - - foreach (var kvp in urlParam) - { - routeParams.Add(kvp.Key, kvp.Value); - } - - return route; - } - - return null; - } - - /// - /// Normalizes a URL request path meant for Wildcard matching and returns the registered - /// path in the internal delegate map. - /// - /// The verb. - /// The context. - /// A string that represents the registered path. - private string NormalizeWildcardPath(HttpVerbs verb, IHttpContext context) - { - var path = context.RequestWilcardPath(_delegateMap.Keys - .Where(k => k.Contains(ModuleMap.AnyPathRoute)) - .Select(s => s.ToLowerInvariant())); - - if (_delegateMap.ContainsKey(path) == false) - return null; - - if (_delegateMap[path].ContainsKey(verb)) - return path; - - var originalPath = context.RequestPath(); - - if (_delegateMap.ContainsKey(originalPath) && - _delegateMap[originalPath].ContainsKey(verb)) - { - return originalPath; - } - - return null; - } - - /// - /// Looks for a path that matches the one provided by the context. - /// - /// The HttpListener context. - /// true if the path is found, otherwise false. - private bool IsMethodNotAllowed(IHttpContext context) - { - string path; - - switch (Server.RoutingStrategy) - { - case RoutingStrategy.Wildcard: - path = context.RequestWilcardPath(_delegateMap.Keys - .Where(k => k.Contains(ModuleMap.AnyPathRoute)) - .Select(s => s.ToLowerInvariant())); - break; - case RoutingStrategy.Regex: - path = context.Request.Url.LocalPath; - foreach (var route in _delegateMap.Keys) - { - if (path.RequestRegexUrlParams(route) != null) - return true; - } - - return false; - default: - path = context.RequestPath(); - break; - } - - return _delegateMap.ContainsKey(path); - } - - private async Task TryHandleWebApi(IHttpContext context, bool responseJsonException) - { - var verb = context.RequestVerb(); - var regExRouteParams = new Dictionary(); - var path = Server.RoutingStrategy == RoutingStrategy.Wildcard - ? NormalizeWildcardPath(verb, context) - : NormalizeRegexPath(verb, context, regExRouteParams); - - // return a non-math if no handler hold the route - if (path == null) - { - return IsMethodNotAllowed(context) && Server.OnMethodNotAllowed != null && - await Server.OnMethodNotAllowed(context).ConfigureAwait(false); - } - - // search the path and verb - if (!_delegateMap.TryGetValue(path, out var methods) || - !methods.TryGetValue(verb, out var methodPair)) - throw new InvalidOperationException($"No method found for path {path} and verb {verb}."); - - // ensure module does not return cached responses by default or the custom headers - var controller = methodPair.SetDefaultHeaders(context); - - // Log the handler to be use - $"Handler: {methodPair.MethodCache.ControllerName}.{methodPair.MethodCache.MethodInfo.Name}" - .Debug(nameof(WebApiModule)); - - // Initially, only the server and context objects will be available - var args = new object[methodPair.MethodCache.AdditionalParameters.Count]; - - if (Server.RoutingStrategy == RoutingStrategy.Regex) - methodPair.ParseArguments(regExRouteParams, args); - - if (!responseJsonException) - { - var result = await methodPair.Invoke(controller, args).ConfigureAwait(false); - - (controller as IDisposable)?.Dispose(); - - return result; - } - - try - { - return await methodPair.Invoke(controller, args).ConfigureAwait(false); - } - catch (Exception ex) - { - ex.Log(Name); - return await context.JsonExceptionResponseAsync(ex).ConfigureAwait(false); - } - finally - { - (controller as IDisposable)?.Dispose(); - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketHandlerAttribute.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebSocketHandlerAttribute.cs deleted file mode 100644 index 88a9558ec..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketHandlerAttribute.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - - /// - /// 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.Class)] - public sealed class WebSocketHandlerAttribute - : Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// The path. - /// The argument 'paths' must be specified. - public WebSocketHandlerAttribute(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("The argument 'path' must be specified."); - } - - Path = path; - } - - /// - /// Gets or sets the path. - /// - /// - /// The paths. - /// - public string Path { get; } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsModule.cs deleted file mode 100644 index ef11eac4f..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsModule.cs +++ /dev/null @@ -1,167 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using Constants; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - - /// - /// A WebSockets module conforming to RFC 6455. - /// - public class WebSocketsModule : WebModuleBase, IDisposable - { - /// - /// Holds the collection of paths and WebSockets Servers registered. - /// - private readonly Dictionary _serverMap = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Initializes a new instance of the class. - /// - public WebSocketsModule() - { - AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, async (context, ct) => - { - if (!context.Request.IsWebSocketRequest) - return false; - - string path; - - // retrieve the request path - switch (Server.RoutingStrategy) - { - case RoutingStrategy.Wildcard: - path = context.RequestWilcardPath(_serverMap.Keys - .Where(k => k.Contains(ModuleMap.AnyPathRoute)) - .Select(s => s.ToLowerInvariant())); - break; - case RoutingStrategy.Regex: - path = NormalizeRegexPath(context); - break; - default: - path = context.RequestPath(); - break; - } - - if (string.IsNullOrEmpty(path) || !_serverMap.ContainsKey(path)) - { - return false; - } - - // Accept the WebSocket -- this is a blocking method until the WebSocketCloses - await _serverMap[path].AcceptWebSocket(context, ct).ConfigureAwait(false); - - return true; - }); - } - - /// - public override string Name => nameof(WebSocketsModule); - - /// - /// Registers the web sockets server given a WebSocketsServer Type. - /// - /// The type of WebSocket server. - /// Argument 'path' cannot be null;path. - public void RegisterWebSocketsServer() - where T : WebSocketsServer, new() - { - RegisterWebSocketsServer(typeof(T)); - } - - /// - /// Registers the web sockets server given a WebSocketsServer Type. - /// - /// Type of the socket. - /// socketType. - /// Argument 'socketType' needs a WebSocketHandlerAttribute - socketType. - public void RegisterWebSocketsServer(Type socketType) - { - if (socketType == null) - throw new ArgumentNullException(nameof(socketType)); - - if (!(socketType.GetTypeInfo().GetCustomAttribute() - is WebSocketHandlerAttribute attribute)) - { - throw new ArgumentException( - $"Argument '{nameof(socketType)}' needs a {nameof(WebSocketHandlerAttribute)}", - nameof(socketType)); - } - - _serverMap[attribute.Path] = (WebSocketsServer)Activator.CreateInstance(socketType); - } - - /// - /// Registers the web sockets server given a WebSocketsServer Type. - /// - /// The type of WebSocket server. - /// The path. For example: '/echo'. - /// Argument 'path' cannot be null;path. - public void RegisterWebSocketsServer(string path) - where T : WebSocketsServer, new() - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException("Argument 'path' cannot be null", nameof(path)); - - _serverMap[path] = Activator.CreateInstance(); - } - - /// - /// Registers the web sockets server. - /// - /// The type of WebSocket server. - /// The path. For example: '/echo'. - /// The server. - /// - /// path - /// or - /// server. - /// - public void RegisterWebSocketsServer(string path, T server) - where T : WebSocketsServer - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentNullException(nameof(path)); - - _serverMap[path] = server ?? throw new ArgumentNullException(nameof(server)); - } - - /// - public override void RunWatchdog() - { - foreach (var instance in _serverMap) - instance.Value.CancellationToken = CancellationToken; - } - - /// - public void Dispose() - { - foreach (var server in _serverMap.Select(y => y.Value).ToArray()) - server?.Dispose(); - } - - /// - /// Normalizes a path meant for Regex matching returns the registered - /// path in the internal map. - /// - /// The context. - /// A string that represents the registered path in the internal map. - private string NormalizeRegexPath(IHttpContext context) - { - var path = string.Empty; - - foreach (var route in _serverMap.Keys) - { - var urlParam = context.RequestRegexUrlParams(route); - - if (urlParam == null) continue; - - return route; - } - - return path; - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsServer.cs b/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsServer.cs deleted file mode 100644 index c1b102266..000000000 --- a/src/Unosquare.Labs.EmbedIO/Modules/WebSocketsServer.cs +++ /dev/null @@ -1,439 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Modules -{ - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.Threading; - using System.Threading.Tasks; - using Swan; - using EmbedIO.Constants; - - /// - /// - /// A base class that defines how to handle WebSockets connections. - /// It keeps a list of connected WebSockets and has the basic logic to handle connections - /// and data transmission. - /// - public abstract class WebSocketsServer : IDisposable - { - private const int ReceiveBufferSize = 2048; - - private readonly object _syncRoot = new object(); - private readonly List _mWebSockets = new List(10); - private readonly int _maximumMessageSize; - private bool _isDisposing; - - /// - /// Initializes a new instance of the class. - /// - /// if set to true [enable connection watchdog]. - /// Maximum size of the message in bytes. Enter 0 or negative number to prevent checks. - protected WebSocketsServer(bool enableConnectionWatchdog, int maxMessageSize = 0) - { - _maximumMessageSize = maxMessageSize; - if (enableConnectionWatchdog) - RunConnectionWatchdog(); - } - - /// - /// Initializes a new instance of the class. With dead connection watchdog and no message size checks. - /// - protected WebSocketsServer() - : this(true) - { - // placeholder - } - - /// - /// Gets the Currently-Connected WebSockets. - /// - /// - /// The web sockets. - /// - public ReadOnlyCollection WebSockets - { - get - { - lock (_syncRoot) - { - return new ReadOnlyCollection(_mWebSockets); - } - } - } - - /// - /// Gets or sets the cancellation token. - /// - /// - /// The cancellation token. - /// - public CancellationToken CancellationToken { get; set; } - - /// - /// Gets the name of the server. - /// - /// - /// The name of the server. - /// - public abstract string ServerName { get; } - - /// - /// Gets the Encoding used to use the Send method to send a string. The default is UTF8 per the WebSocket specification. - /// - /// - /// The Encoding to be used. - /// - protected System.Text.Encoding Encoding { get; set; } = System.Text.Encoding.UTF8; - - /// - /// Accepts the WebSocket connection. - /// This is a blocking call so it must be called within an independent thread. - /// - /// The context. - /// The cancellation token. - /// - /// A task that represents the asynchronous of websocket connection operation. - /// - public async Task AcceptWebSocket(IHttpContext context, CancellationToken ct) - { - // first, accept the websocket - $"{ServerName} - Accepting WebSocket . . .".Debug(nameof(WebSocketsServer)); - - var subProtocol = ResolveSubProtocol(context); - var webSocketContext = - await context.AcceptWebSocketAsync(ReceiveBufferSize, subProtocol).ConfigureAwait(false); - - // remove the disconnected clients - CollectDisconnected(); - - lock (_syncRoot) - { - // add the newly-connected client - _mWebSockets.Add(webSocketContext); - } - - $"{ServerName} - WebSocket Accepted - There are {WebSockets.Count} sockets connected.".Debug( - nameof(WebSocketsServer)); - - OnClientConnected(webSocketContext, context.Request.LocalEndPoint, context.Request.RemoteEndPoint); - - try - { - if (webSocketContext.WebSocket is WebSocket systemWebSocket) - { - await ProcessSystemWebsocket(webSocketContext, systemWebSocket.SystemWebSocket, ct) - .ConfigureAwait(false); - } - else - { - await ProcessEmbedIOWebSocket(webSocketContext, ct).ConfigureAwait(false); - } - } - catch (TaskCanceledException) - { - // ignore - } - catch (Exception ex) - { - ex.Log(nameof(WebSocketsServer)); - } - finally - { - // once the loop is completed or connection aborted, remove the WebSocket - RemoveWebSocket(webSocketContext); - } - } - - /// - public void Dispose() - { - if (_isDisposing) return; - - _isDisposing = true; - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Sends a UTF-8 payload. - /// - /// The web socket. - /// The payload. - protected virtual async void Send(IWebSocketContext webSocket, string payload) - { - try - { - var buffer = Encoding.GetBytes(payload ?? string.Empty); - - await webSocket.WebSocket.SendAsync(buffer, true, CancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - ex.Log(nameof(WebSocketsServer)); - } - } - - /// - /// Sends a binary payload. - /// - /// The web socket. - /// The payload. - protected virtual async void Send(IWebSocketContext webSocket, byte[] payload) - { - try - { - await webSocket.WebSocket.SendAsync(payload ?? Array.Empty(), false, CancellationToken) - .ConfigureAwait(false); - } - catch (Exception ex) - { - ex.Log(nameof(WebSocketsServer)); - } - } - - /// - /// Broadcasts the specified payload to all connected WebSockets clients. - /// - /// The payload. - protected virtual void Broadcast(byte[] payload) - { - foreach (var wsc in WebSockets) - Send(wsc, payload); - } - - /// - /// Broadcasts the specified payload to all connected WebSockets clients. - /// - /// The payload. - protected virtual void Broadcast(string payload) - { - foreach (var wsc in WebSockets) - Send(wsc, payload); - } - - /// - /// Closes the specified web socket, removes it and disposes it. - /// - /// The web socket. - protected virtual async void Close(IWebSocketContext webSocket) - { - if (webSocket == null) - return; - - try - { - await webSocket.WebSocket.CloseAsync(CancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - ex.Log(nameof(WebSocketsServer)); - } - finally - { - RemoveWebSocket(webSocket); - } - } - - /// - /// Resolves the sub-protocol to use with the incoming WebSocket connection. - /// - /// When no using a sub-protocol return null. - /// - /// The context. - /// The sub-protocol to be used, or null if it does not. - protected virtual string ResolveSubProtocol(IHttpContext context) - { - return null; - } - - /// - /// Called when this WebSockets Server receives a full message (EndOfMessage) form a WebSockets client. - /// - /// The context. - /// The buffer. - /// The result. - protected abstract void OnMessageReceived( - IWebSocketContext context, - byte[] buffer, - IWebSocketReceiveResult result); - - /// - /// Called when this WebSockets Server receives a message frame regardless if the frame represents the EndOfMessage. - /// - /// The context. - /// The buffer. - /// The result. - protected abstract void OnFrameReceived( - IWebSocketContext context, - byte[] buffer, - IWebSocketReceiveResult result); - - /// - /// Called when this WebSockets Server accepts a new WebSockets client. - /// - /// The context. - /// The local endpoint. - /// The remote endpoint. - protected abstract void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint); - - /// - /// Called when the server has removed a WebSockets connected client for any reason. - /// - /// The context. - protected abstract void OnClientDisconnected(IWebSocketContext context); - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposeAll) - { - // We only have managed resources here. - // if called with false, return. - if (!disposeAll) return; - - lock (_syncRoot) - { - foreach (var socket in _mWebSockets.ToArray()) - Close(socket); - } - - CollectDisconnected(); - } - - /// - /// Runs the connection watchdog. - /// Removes and disposes stale WebSockets connections every 10 minutes. - /// - private void RunConnectionWatchdog() - { - Task.Run(async () => { - while (_isDisposing == false) - { - if (_isDisposing == false) - CollectDisconnected(); - - // TODO: make this sleep configurable. - await Task.Delay(TimeSpan.FromSeconds(30), CancellationToken).ConfigureAwait(false); - } - }, CancellationToken); - } - - /// - /// Removes and disposes the web socket. - /// - /// The web socket context. - private void RemoveWebSocket(IWebSocketContext webSocketContext) - { - webSocketContext.WebSocket?.Dispose(); - - lock (_syncRoot) - { - _mWebSockets.Remove(webSocketContext); - } - - OnClientDisconnected(webSocketContext); - } - - /// - /// Removes and disposes all disconnected sockets. - /// - private void CollectDisconnected() - { - var collectedCount = 0; - lock (_syncRoot) - { - for (var i = _mWebSockets.Count - 1; i >= 0; i--) - { - var currentSocket = _mWebSockets[i]; - - if (currentSocket.WebSocket == null || currentSocket.WebSocket.State == Net.WebSocketState.Open) - continue; - - RemoveWebSocket(currentSocket); - collectedCount++; - } - } - - $"{ServerName} - Collected {collectedCount} sockets. WebSocket Count: {WebSockets.Count}".Debug( - nameof(WebSocketsServer)); - } - - private async Task ProcessEmbedIOWebSocket(IWebSocketContext webSocketContext, CancellationToken ct) - { - ((Net.WebSocket) webSocketContext.WebSocket).OnMessage += async (s, e) => { - if (e.Opcode == Net.Opcode.Close) - { - await webSocketContext.WebSocket.CloseAsync(CancellationToken).ConfigureAwait(false); - return; - } - - OnMessageReceived(webSocketContext, - e.RawData, - new Net.WebSocketReceiveResult(e.RawData.Length, e.Opcode)); - }; - - while (webSocketContext.WebSocket.State == Net.WebSocketState.Open || - webSocketContext.WebSocket.State == Net.WebSocketState.Closing) - { - await Task.Delay(500, ct).ConfigureAwait(false); - } - } - - private async Task ProcessSystemWebsocket( - IWebSocketContext context, - System.Net.WebSockets.WebSocket webSocket, - CancellationToken ct) - { - // define a receive buffer - var receiveBuffer = new byte[ReceiveBufferSize]; - - // define a dynamic buffer that holds multi-part receptions - var receivedMessage = new List(receiveBuffer.Length * 2); - - // poll the WebSockets connections for reception - while (webSocket.State == System.Net.WebSockets.WebSocketState.Open) - { - // retrieve the result (blocking) - var receiveResult = new WebSocketReceiveResult(await webSocket - .ReceiveAsync(new ArraySegment(receiveBuffer), ct).ConfigureAwait(false)); - - if (receiveResult.MessageType == (int) System.Net.WebSockets.WebSocketMessageType.Close) - { - // close the connection if requested by the client - await webSocket - .CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, ct) - .ConfigureAwait(false); - return; - } - - var frameBytes = new byte[receiveResult.Count]; - Array.Copy(receiveBuffer, frameBytes, frameBytes.Length); - OnFrameReceived(context, frameBytes, receiveResult); - - // add the response to the multi-part response - receivedMessage.AddRange(frameBytes); - - if (receivedMessage.Count > _maximumMessageSize && _maximumMessageSize > 0) - { - // close the connection if message exceeds max length - await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig, - $"Message too big. Maximum is {_maximumMessageSize} bytes.", - ct).ConfigureAwait(false); - - // exit the loop; we're done - return; - } - - // if we're at the end of the message, process the message - if (!receiveResult.EndOfMessage) continue; - - OnMessageReceived(context, receivedMessage.ToArray(), receiveResult); - receivedMessage.Clear(); - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/SessionInfo.cs b/src/Unosquare.Labs.EmbedIO/SessionInfo.cs deleted file mode 100644 index 5ca6b48bf..000000000 --- a/src/Unosquare.Labs.EmbedIO/SessionInfo.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using System; - using System.Collections.Concurrent; - - /// - /// Represents the contents of an HTTP Session. - /// - public class SessionInfo - { - private readonly Lazy> _lazyData = - new Lazy>(() => - new ConcurrentDictionary(Strings.StandardStringComparer)); - - /// - /// Initializes a new instance of the class. - /// - /// The session identifier. - public SessionInfo(string sessionId) - { - DateCreated = DateTime.UtcNow; - LastActivity = DateTime.UtcNow; - SessionId = sessionId; - } - - /// - /// Current Session Identifier. - /// - public string SessionId { get; } - - /// - /// Gets or sets the date created. - /// - /// - /// The date created. - /// - public DateTime DateCreated { get; } - - /// - /// Gets or sets the last activity. - /// - /// - /// The last activity. - /// - public DateTime LastActivity { get; set; } - - /// - /// Current Session Data Repository. - /// - public ConcurrentDictionary Data => _lazyData.Value; - - /// - /// Retrieve an item or set an item. If the key does not exist, it returns null. - /// This is an indexer providing a shortcut to the underlying Data dictionary. - /// - /// The key as an indexer. - /// An object that represents current session data repository. - public object this[string key] - { - get => Data.ContainsKey(key) ? Data[key] : null; - set => Data[key] = value; - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerContext.cs b/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerContext.cs deleted file mode 100644 index aa41df9b1..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerContext.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Unosquare.Net -{ - using System; - using System.Collections.Generic; - using System.Security.Principal; - using System.Threading.Tasks; - using Labs.EmbedIO; - - /// - /// Provides access to the request and response objects used by the HttpListener class. - /// This class cannot be inherited. - /// - /// - internal sealed class HttpListenerContext : IHttpContext - { - private WebSocketContext _websocketContext; - private Lazy> _items = - new Lazy>(() => new Dictionary(), true); - - internal HttpListenerContext(HttpConnection cnc) - { - Id = Guid.NewGuid(); - Connection = cnc; - Request = new HttpListenerRequest(this); - Response = new HttpListenerResponse(this); - User = null; - } - - /// - public IHttpRequest Request { get; } - - /// - public IHttpResponse Response { get; } - - /// - public IPrincipal User { get; } - - /// - public IWebServer WebServer { get; set; } - - /// - public IDictionary Items - { - get => _items.Value; - set => _items = new Lazy>(() => value, true); - } - - 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; } - - internal Guid Id { get; } - - /// - public async Task AcceptWebSocketAsync(int receiveBufferSize, string subProtocol = null) - { - if (_websocketContext != null) - throw new InvalidOperationException("The accepting is already in progress."); - - _websocketContext = new WebSocketContext(this); - await ((WebSocket) _websocketContext.WebSocket).InternalAcceptAsync().ConfigureAwait(false); - - return _websocketContext; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerException.cs b/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerException.cs deleted file mode 100644 index 9739db06e..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpListenerException.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Unosquare.Net -{ - using System; - - /// - /// Represents an HTTP Listener's exception. - /// - internal class HttpListenerException : Exception - { - internal HttpListenerException(int errorCode, string message) - : base(message) - { - ErrorCode = errorCode; - } - - /// - /// Gets the error code. - /// - public int ErrorCode { get; } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/HttpVersion.cs b/src/Unosquare.Labs.EmbedIO/System.Net/HttpVersion.cs deleted file mode 100644 index c0b0c5f73..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/HttpVersion.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Unosquare.Net -{ - using System; - - /// - /// Define HTTP Versions. - /// - internal static class HttpVersion - { - /// - /// The version 1.0. - /// - public static readonly Version Version10 = new Version(1, 0); - - /// - /// The version 1.1. - /// - public static readonly Version Version11 = new Version(1, 1); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/MessageEventArgs.cs b/src/Unosquare.Labs.EmbedIO/System.Net/MessageEventArgs.cs deleted file mode 100644 index aa24b6b41..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/MessageEventArgs.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace Unosquare.Net -{ - using System; - using Swan; - - internal class MessageEventArgs : EventArgs - { - private readonly byte[] _rawData; - private string _data; - private bool _dataSet; - - internal MessageEventArgs(WebSocketFrame frame) - { - Opcode = frame.Opcode; - _rawData = frame.PayloadData.ApplicationData.ToArray(); - } - - internal MessageEventArgs(Opcode opcode, byte[] rawData) - { - if ((ulong)rawData.Length > PayloadData.MaxLength) - throw new WebSocketException(CloseStatusCode.TooBig); - - Opcode = opcode; - _rawData = rawData; - } - - public string Data - { - get - { - SetData(); - return _data; - } - } - - public bool IsText => Opcode == Opcode.Text; - - public byte[] RawData - { - get - { - SetData(); - return _rawData; - } - } - - internal Opcode Opcode { get; } - - private void SetData() - { - if (_dataSet) - return; - - if (Opcode == Opcode.Binary) - { - _dataSet = true; - return; - } - - _data = _rawData.ToText(); - _dataSet = true; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/NetExtensions.cs b/src/Unosquare.Labs.EmbedIO/System.Net/NetExtensions.cs deleted file mode 100644 index aab47355a..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/NetExtensions.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace Unosquare.Net -{ - using System.Collections.Generic; - using System.Collections.Specialized; - using System.Linq; - using System.Text; - using System; - using Labs.EmbedIO.Constants; - using Swan; - - /// - /// Represents some System.NET custom extensions. - /// - internal static class NetExtensions - { - internal const string Tspecials = "()<>@,;:\\\"/[]?={} \t"; - - internal static IEnumerable SplitHeaderValue(this string value, params char[] separators) - { - var len = value.Length; - var seps = new string(separators); - - var buff = new StringBuilder(32); - var escaped = false; - var quoted = false; - - for (var i = 0; i < len; i++) - { - var c = value[i]; - - if (c == '"') - { - if (escaped) - escaped = false; - else - quoted = !quoted; - } - else if (c == '\\') - { - if (i < len - 1 && value[i + 1] == '"') - escaped = true; - } - else if (seps.Contains(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(); - } - - 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)); - } - - internal static bool IsToken(this string value) => - value.All(c => c >= 0x20 && c < 0x7f && !Tspecials.Contains(c)); - - internal static string ToExtensionString(this CompressionMethod method, params string[] parameters) - { - if (method == CompressionMethod.None) - return string.Empty; - - var m = $"permessage-{method.ToString().ToLower()}"; - - return parameters == null || parameters.Length == 0 ? m : $"{m}; {string.Join("; ", parameters)}"; - } - - internal static bool Contains(this NameValueCollection collection, string name, string value) - => collection[name]?.Split(Strings.CommaSplitChar) - .Any(val => val.Trim().Equals(value, StringComparison.OrdinalIgnoreCase)) == true; - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebHeaderCollection.cs b/src/Unosquare.Labs.EmbedIO/System.Net/WebHeaderCollection.cs deleted file mode 100644 index 45234f5fa..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebHeaderCollection.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace Unosquare.Net -{ - using System; - using System.Collections.Specialized; - using System.Linq; - using System.Text; - - internal class WebHeaderCollection - : NameValueCollection - { - public override string ToString() - { - var buff = new StringBuilder(); - - foreach (string key in Keys) - buff.AppendFormat("{0}: {1}\r\n", key, Get(key)); - - return buff.Append("\r\n").ToString(); - } - - public override void Add(string name, string value) => base.Add(name, CheckValue(value)); - - internal static bool IsHeaderValue(string value) - { - var len = value.Length; - for (var i = 0; i < len; i++) - { - var c = value[i]; - if (c < 0x20 && !"\r\n\t".Contains(c)) - return false; - - if (c == 0x7f) - return false; - - if (c != '\n' || ++i >= len) continue; - - c = value[i]; - if (!" \t".Contains(c)) - return false; - } - - return true; - } - - private static string CheckValue(string value) - { - if (string.IsNullOrEmpty(value)) - return string.Empty; - - var trimValue = value.Trim(); - - if (trimValue.Length > 65535) - throw new ArgumentOutOfRangeException(nameof(value), "Greater than 65,535 characters."); - - if (!IsHeaderValue(trimValue)) - throw new ArgumentException("Contains invalid characters.", nameof(value)); - - return trimValue; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketContext.cs b/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketContext.cs deleted file mode 100644 index 0e502aa2e..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketContext.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Unosquare.Net -{ - using System; - using Labs.EmbedIO; - using System.Collections.Specialized; - using System.IO; - - /// - /// Provides the properties used to access the information in - /// a WebSocket handshake request received by the . - /// - /// - internal class WebSocketContext - : IWebSocketContext - { - private readonly HttpListenerContext _context; - - internal WebSocketContext(HttpListenerContext context) - { - _context = context; - WebSocket = new WebSocket(this); - } - - /// - public ICookieCollection CookieCollection => _context.Request.Cookies; - - /// - /// Gets the HTTP headers included in the request. - /// - /// - /// A that contains the headers. - /// - public NameValueCollection Headers => _context.Request.Headers; - - /// - /// Gets a value indicating whether the WebSocket connection is secured. - /// - /// - /// true if the connection is secured; otherwise, false. - /// - public bool IsSecureConnection => _context.Connection.IsSecure; - - /// - /// Gets a value indicating whether the request is a WebSocket handshake request. - /// - /// - /// true if the request is a WebSocket handshake request; otherwise, false. - /// - public bool IsWebSocketRequest => _context.Request.IsWebSocketRequest; - - /// - /// Gets the URI requested by the client. - /// - /// - /// A that represents the requested URI. - /// - public Uri RequestUri => _context.Request.Url; - - /// - public IWebSocket WebSocket { get; } - - internal Stream Stream => _context.Connection.Stream; - - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() => _context.Request.ToString(); - - internal void CloseAsync() => _context.Connection.Close(true); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketKey.cs b/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketKey.cs deleted file mode 100644 index 52da773b2..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketKey.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Unosquare.Net -{ - using System; - using System.Text; - using System.Security.Cryptography; - - internal class WebSocketKey - { - private const string Guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - - public string KeyValue { get; set; } - - internal string CreateResponseKey() - { - var buff = new StringBuilder(KeyValue, 64); - buff.Append(Guid); -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - var sha1 = SHA1.Create(); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms - var src = sha1.ComputeHash(Encoding.UTF8.GetBytes(buff.ToString())); - - return Convert.ToBase64String(src); - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketState.cs b/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketState.cs deleted file mode 100644 index e521ab1a0..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketState.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Unosquare.Net -{ - /// - /// Indicates the state of a WebSocket connection. - /// - /// - /// The values of this enumeration are defined in - /// The WebSocket API. - /// - public enum WebSocketState : ushort - { - /// - /// Equivalent to numeric value 0. Indicates that the connection hasn't yet been established. - /// - Connecting = 0, - - /// - /// Equivalent to numeric value 1. Indicates that the connection has been established, - /// and the communication is possible. - /// - Open = 1, - - /// - /// Equivalent to numeric value 2. Indicates that the connection is going through - /// the closing handshake, or the WebSocket.Close method has been invoked. - /// - Closing = 2, - - /// - /// Equivalent to numeric value 3. Indicates that the connection has been closed or - /// couldn't be established. - /// - Closed = 3, - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketValidator.cs b/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketValidator.cs deleted file mode 100644 index 4665b1fb0..000000000 --- a/src/Unosquare.Labs.EmbedIO/System.Net/WebSocketValidator.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace Unosquare.Net -{ - using Labs.EmbedIO.Constants; - using Swan; - using System.Text; - - internal class WebSocketValidator - { - private readonly WebSocket _webSocket; - - public WebSocketValidator(WebSocket webSocket) - { - _webSocket = webSocket; - } - - internal static bool CheckParametersForClose(CloseStatusCode code, string reason) - { - if (code == CloseStatusCode.NoStatus && !string.IsNullOrEmpty(reason)) - { - "'code' cannot have a reason.".Trace(nameof(CheckParametersForClose)); - return false; - } - - if (code == CloseStatusCode.MandatoryExtension) - { - "'code' cannot be used by a server.".Trace(nameof(CheckParametersForClose)); - return false; - } - - if (!string.IsNullOrEmpty(reason) && Encoding.UTF8.GetBytes(reason).Length > 123) - { - "The size of 'reason' is greater than the allowable max size.".Trace(nameof(CheckParametersForClose)); - return false; - } - - return true; - } - - internal bool CheckIfAvailable(bool connecting = true) - { - if (connecting || _webSocket.State != WebSocketState.Connecting) return true; - - "This operation isn't available in: connecting".Trace(nameof(CheckIfAvailable)); - return false; - } - - // As server - internal void ThrowIfInvalid(WebSocketContext context) - { - if (context.RequestUri == null) - { - throw new WebSocketException(CloseStatusCode.ProtocolError, "Specifies an invalid Request-URI."); - } - - if (!context.IsWebSocketRequest) - { - throw new WebSocketException(CloseStatusCode.ProtocolError, "Not a WebSocket handshake request."); - } - - var headers = context.Headers; - if (string.IsNullOrEmpty(headers[HttpHeaders.WebSocketKey])) - { - throw new WebSocketException(CloseStatusCode.ProtocolError, $"Includes no {HttpHeaders.WebSocketKey} header, or it has an invalid value."); - } - - if (!ValidateSecWebSocketVersionClientHeader(headers[HttpHeaders.WebSocketVersion])) - { - throw new WebSocketException(CloseStatusCode.ProtocolError, $"Includes no {HttpHeaders.WebSocketVersion} header, or it has an invalid value."); - } - - if (!_webSocket.IgnoreExtensions - && !string.IsNullOrWhiteSpace(headers[HttpHeaders.WebSocketExtensions])) - { - throw new WebSocketException(CloseStatusCode.ProtocolError, $"Includes an invalid {HttpHeaders.WebSocketExtensions} header."); - } - } - - private static bool ValidateSecWebSocketVersionClientHeader(string value) => value != null && value == Strings.WebSocketVersion; - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpClient.cs b/src/Unosquare.Labs.EmbedIO/Tests/TestHttpClient.cs deleted file mode 100644 index f8d3be95d..000000000 --- a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpClient.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System; - using System.Text; - using System.Threading.Tasks; - - /// - /// Represents a HTTP Client for unit testing. - /// - /// - public class TestHttpClient - { - /// - /// Initializes a new instance of the class. - /// - /// The server. - /// The encoding. - public TestHttpClient(IWebServer server, Encoding encoding = null) - { - WebServer = server; - Encoding = encoding ?? Encoding.UTF8; - } - - /// - /// Gets or sets the web server. - /// - /// - /// The web server. - /// - public IWebServer WebServer { get; set; } - - /// - /// Gets or sets the encoding. - /// - /// - /// The encoding. - /// - public Encoding Encoding { get; set; } - - /// - /// Gets the asynchronous. - /// - /// The URL. - /// - /// A task representing the GET call. - /// - public async Task GetAsync(string url = "") - { - var response = await SendAsync(new TestHttpRequest($"http://test/{url}")).ConfigureAwait(false); - - return response.GetBodyAsString(Encoding); - } - - /// - /// Sends the HTTP request asynchronous. - /// - /// The request. - /// A task representing the HTTP response. - /// The IWebServer implementation should be TestWebServer. - public async Task SendAsync(TestHttpRequest request) - { - var context = new TestHttpContext(request, WebServer); - - if (!(WebServer is TestWebServer testServer)) - throw new InvalidOperationException($"The {nameof(IWebServer)} implementation should be {nameof(TestWebServer)}."); - - testServer.HttpContexts.Enqueue(context); - - if (!(context.Response is TestHttpResponse response)) - throw new InvalidOperationException("The response object is invalid."); - - try - { - while (!response.IsClosed) - await Task.Delay(1).ConfigureAwait(false); - } - catch - { - // ignore - } - - return response; - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpContext.cs b/src/Unosquare.Labs.EmbedIO/Tests/TestHttpContext.cs deleted file mode 100644 index 5d01bd257..000000000 --- a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpContext.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using System.Security.Principal; - - /// - /// Represents a Test Http Context. - /// - /// - public class TestHttpContext : IHttpContext - { - private Lazy> _items = - new Lazy>(() => new Dictionary(), true); - - /// - /// Initializes a new instance of the class. - /// - /// The request. - /// The webserver. - public TestHttpContext(IHttpRequest request, IWebServer webserver) - { - Request = request; - WebServer = webserver; - } - - /// - public IHttpRequest Request { get; } - - /// - public IHttpResponse Response { get; } = new TestHttpResponse(); - - /// - public IPrincipal User { get; } - - /// - public IWebServer WebServer { get; set; } - - /// - public IDictionary Items - { - get => _items.Value; - set => _items = new Lazy>(() => value, true); - } - - /// - /// - public Task AcceptWebSocketAsync(int receiveBufferSize, string subProtocol = null) => throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpRequest.cs b/src/Unosquare.Labs.EmbedIO/Tests/TestHttpRequest.cs deleted file mode 100644 index c6f97b9d6..000000000 --- a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpRequest.cs +++ /dev/null @@ -1,103 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System; - using System.Collections.Specialized; - using System.IO; - using System.Net; - using System.Text; - using Constants; - - /// - /// Represents an IHttpRequest implementation for unit testing. - /// - /// - public class TestHttpRequest : IHttpRequest - { - private const string DefaultTestUrl = "http://test/"; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP method. - public TestHttpRequest(HttpVerbs httpMethod = HttpVerbs.Get) - : this(DefaultTestUrl, httpMethod) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The URL. - /// The HTTP method. - public TestHttpRequest(string url, HttpVerbs httpMethod = HttpVerbs.Get) - { - RawUrl = url ?? throw new ArgumentNullException(nameof(url)); - - HttpMethod = httpMethod.ToString(); - Url = new Uri(url); - } - - /// - public NameValueCollection Headers { get; } = new NameValueCollection(); - - /// - public Version ProtocolVersion { get; } = Net.HttpVersion.Version11; - - /// - public bool KeepAlive { get; } - - /// - public ICookieCollection Cookies { get; } - - /// - public string RawUrl { get; } - - /// - public NameValueCollection QueryString { get; } = new NameValueCollection(); - - /// - public string HttpMethod { get; } - - /// - public Uri Url { get; } - - /// - public bool HasEntityBody { get; } - - /// - public Stream InputStream { get; } - - /// - public Encoding ContentEncoding { get; } - - /// - public IPEndPoint RemoteEndPoint { get; } - - /// - public bool IsLocal { get; } = true; - - /// - public string UserAgent { get; } - - /// - public bool IsWebSocketRequest { get; } - - /// - public IPEndPoint LocalEndPoint { get; } - - /// - public string ContentType { get; } - - /// - public long ContentLength64 { get; } - - /// - public bool IsAuthenticated { get; } - - /// - public Uri UrlReferrer { get; } - - /// - public Guid RequestTraceIdentifier { get; } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpResponse.cs b/src/Unosquare.Labs.EmbedIO/Tests/TestHttpResponse.cs deleted file mode 100644 index bc0e0b442..000000000 --- a/src/Unosquare.Labs.EmbedIO/Tests/TestHttpResponse.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System; - using System.Collections.Specialized; - using System.IO; - using System.Net; - using System.Text; - - /// - /// Represents an IHttpResponse implementation for unit testing. - /// - /// - public class TestHttpResponse : IHttpResponse, IDisposable - { - /// - public NameValueCollection Headers { get; } = new NameValueCollection(); - - /// - public int StatusCode { get; set; } = (int)HttpStatusCode.OK; - - /// - public long ContentLength64 { get; set; } - - /// - public string ContentType { get; set; } - - /// - public Stream OutputStream { get; } = new MemoryStream(); - - /// - public ICookieCollection Cookies { get; } = new Net.CookieCollection(); - - /// - public Encoding ContentEncoding { get; } = Encoding.UTF8; - - /// - public bool KeepAlive { get; set; } - - /// - public Version ProtocolVersion { get; } = Net.HttpVersion.Version11; - - /// - /// Gets the body. - /// - /// - /// The body. - /// - public byte[] Body { get; private set; } - - /// - public string StatusDescription { get; set; } - - internal bool IsClosed { get; private set; } - - /// - public void AddHeader(string headerName, string value) => Headers.Add(headerName, value); - - /// - public void SetCookie(Cookie sessionCookie) => Cookies.Add(sessionCookie); - - /// - public void Close() - { - IsClosed = true; - Body = (OutputStream as MemoryStream)?.ToArray(); - - Dispose(); - } - - /// - public void Dispose() - { - OutputStream?.Dispose(); - } - - /// - /// Gets the body as string. - /// - /// The encoding. - /// A string from the body. - public string GetBodyAsString(Encoding encoding = null) - { - if (!(OutputStream is MemoryStream ms)) return null; - - var result = (encoding ?? Encoding.UTF8).GetString(ms.ToArray()); - - // Remove BOM - return result.Length > 0 && result[0] == 65279 ? result.Remove(0, 1) : result; - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/Tests/TestWebServer.cs b/src/Unosquare.Labs.EmbedIO/Tests/TestWebServer.cs deleted file mode 100644 index 0ecf2d9f8..000000000 --- a/src/Unosquare.Labs.EmbedIO/Tests/TestWebServer.cs +++ /dev/null @@ -1,155 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System; - using System.Collections.Concurrent; - using System.Collections.ObjectModel; - using System.Threading; - using System.Threading.Tasks; - using Constants; - using Swan; - using Core; - - /// - /// Represents our tiny web server used to handle requests for testing. - /// - /// Use this IWebServer implementation to run your unit tests. - /// - public class TestWebServer : IWebServer, IDisposable - { - private readonly WebModules _modules = new WebModules(); - - /// - /// Initializes a new instance of the class. - /// - /// The routing strategy. - public TestWebServer(RoutingStrategy routingStrategy = RoutingStrategy.Wildcard) - { - Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; - - RoutingStrategy = routingStrategy; - State = WebServerState.Listening; - } - - /// - /// Finalizes an instance of the class. - /// - ~TestWebServer() - { - Dispose(false); - } - - /// - public event WebServerStateChangedEventHandler StateChanged; - - /// - public ISessionWebModule SessionModule => _modules.SessionModule; - - /// - public RoutingStrategy RoutingStrategy { get; } - - /// - public ReadOnlyCollection Modules => _modules.Modules; - - /// - public Func> OnMethodNotAllowed { get; set; } = ctx => - ctx.HtmlResponseAsync(Responses.Response405Html, System.Net.HttpStatusCode.MethodNotAllowed); - - /// - public Func> OnNotFound { get; set; } = ctx => - ctx.HtmlResponseAsync(Responses.Response404Html, System.Net.HttpStatusCode.NotFound); - - /// - public Func> UnhandledException { get; set; } - - /// - /// Gets the HTTP contexts. - /// - /// - /// The HTTP contexts. - /// - public ConcurrentQueue HttpContexts { get; } = new ConcurrentQueue(); - - /// - public WebServerState State { get; } - - /// - /// Gets a value indicating whether this has been disposed. - /// - /// - /// true if disposed; otherwise, false. - /// - protected bool Disposed { get; private set; } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - public T Module() - where T : class, IWebModule - { - return _modules.Module(); - } - - /// - public void RegisterModule(IWebModule webModule) => _modules.RegisterModule(webModule, this); - - /// - public void UnregisterModule(Type moduleType) => _modules.UnregisterModule(moduleType); - - /// - public async Task RunAsync(CancellationToken ct = default) - { - while (!ct.IsCancellationRequested) - { - var clientSocket = await GetContextAsync(ct).ConfigureAwait(false); - - if (ct.IsCancellationRequested || clientSocket == null) - return; - - // Usually we don't wait, but for testing let's do it. - var handler = new HttpHandler(clientSocket); - await handler.HandleClientRequest(ct).ConfigureAwait(false); - } - } - - /// - /// Gets the test HTTP Client. - /// - /// A new instance of the TestHttpClient. - public TestHttpClient GetClient() => new TestHttpClient(this); - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (!disposing || Disposed) return; - - try - { - _modules.Dispose(); - } - finally - { - Disposed = true; - } - } - - private async Task GetContextAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - if (HttpContexts.TryDequeue(out var entry)) return entry; - - await Task.Delay(100, ct).ConfigureAwait(false); - } - - return null; - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/WebModuleBase.cs b/src/Unosquare.Labs.EmbedIO/WebModuleBase.cs deleted file mode 100644 index 1d2deffa8..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebModuleBase.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using System; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a Web Handler. - /// - /// The context. - /// The cancellation token. - /// A task representing the success of the web handler. - [Obsolete("WebHandler signature will change to: Task WebHandler(IHttpContext context, string path, CancellationToken cancellationToken)")] - public delegate Task WebHandler(IHttpContext context, CancellationToken ct); - - /// - /// Base class to define custom web modules. - /// Inherit from this class and use the AddHandler Method to register your method calls. - /// - public abstract class WebModuleBase - : IWebModule - { - /// - /// Initializes a new instance of the class. - /// - protected WebModuleBase() - { - Handlers = new ModuleMap(); - } - - /// - public abstract string Name { get; } - - /// - public ModuleMap Handlers { get; protected set; } - - /// - public IWebServer Server { get; set; } - - /// - public bool IsWatchdogEnabled { get; set; } - - /// - public TimeSpan WatchdogInterval { get; set; } = TimeSpan.FromSeconds(30); - - /// - public CancellationToken CancellationToken { get; protected set; } - - /// - public void AddHandler(string path, HttpVerbs verb, WebHandler handler) - { - if (path == null) - throw new ArgumentNullException(nameof(path)); - - if (handler == null) - throw new ArgumentNullException(nameof(handler)); - - Handlers.Add(new Map {Path = path, Verb = verb, ResponseHandler = handler}); - } - - /// - public void Start(CancellationToken cancellationToken) - { - CancellationToken = cancellationToken; - - Task.Run(async () => - { - try - { - while (!cancellationToken.IsCancellationRequested) - { - RunWatchdog(); - await Task.Delay(WatchdogInterval, cancellationToken).ConfigureAwait(false); - } - } - catch (TaskCanceledException) - { - // ignore - } - }, cancellationToken); - } - - /// - public virtual void RunWatchdog() - { - // do nothing - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/WebModules.cs b/src/Unosquare.Labs.EmbedIO/WebModules.cs deleted file mode 100644 index 5b1a69a3c..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebModules.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Swan; - using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.Linq; - using System.Threading; - - internal sealed class WebModules : IDisposable - { - private readonly List _modules = new List(4); - - ~WebModules() - { - Dispose(false); - } - - public ISessionWebModule SessionModule { get; private set; } - - public ReadOnlyCollection Modules => _modules.AsReadOnly(); - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public T Module() - where T : class, IWebModule - { - return Module(typeof(T)) as T; - } - - public void RegisterModule(IWebModule module, IWebServer webServer) - { - if (module == null) return; - var existingModule = Module(module.GetType()); - - if (existingModule == null) - { - module.Server = webServer; - _modules.Add(module); - - if (module is ISessionWebModule webModule) - SessionModule = webModule; - } - else - { - $"Failed to register module '{module.GetType()}' because a module with the same type already exists." - .Warn(nameof(WebServer)); - } - } - - public void UnregisterModule(Type moduleType) - { - var existingModule = Module(moduleType); - - if (existingModule == null) - { - $"Failed to unregister module '{moduleType}' because no module with that type has been previously registered." - .Warn(nameof(WebServer)); - - return; - } - - var module = Module(moduleType); - _modules.Remove(module); - - if (module is IDisposable disposable) - disposable.Dispose(); - - if (module == SessionModule) - SessionModule = null; - } - - public void StartModules(IWebServer webServer, CancellationToken ct) - { - foreach (var module in _modules) - { - module.Server = webServer; - module.Start(ct); - } - } - - private IWebModule Module(Type moduleType) => _modules.FirstOrDefault(m => m.GetType() == moduleType); - - private void Dispose(bool disposing) - { - if (!disposing) return; - - foreach (var disposable in _modules.OfType()) - disposable.Dispose(); - - _modules.Clear(); - } - } -} diff --git a/src/Unosquare.Labs.EmbedIO/WebServer.cs b/src/Unosquare.Labs.EmbedIO/WebServer.cs deleted file mode 100644 index b4ad0265c..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebServer.cs +++ /dev/null @@ -1,354 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using Core; - using System.Collections.Generic; - using Swan; - using System; - using System.Collections.ObjectModel; - using System.Threading; - using System.Threading.Tasks; - using System.Security.Cryptography.X509Certificates; - using Net; - - /// - /// Represents our tiny web server used to handle requests. - /// - /// This is the default implementation of IWebServer and it's ready to select - /// the IHttpListener implementation via the proper constructor. - /// - /// By default, the WebServer will use the Regex RoutingStrategy for - /// all registered modules (IWebModule) and EmbedIO Listener (HttpListenerMode). - /// - public class WebServer : IWebServer, IDisposable - { - private readonly WebModules _modules = new WebModules(); - - private WebServerState _state = WebServerState.Created; - - /// - /// Initializes a new instance of the class. - /// - /// Default settings are Regex RoutingStrategy, EmbedIO HttpListenerMode, and binding all - /// network interfaces with HTTP protocol and default port (http://*:80/). - /// - public WebServer() - : this(80) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// Default settings are Regex RoutingStrategy, EmbedIO HttpListenerMode, and binding all - /// network interfaces with HTTP protocol with the selected port (http://*:{port}/). - /// - /// The port. - /// The strategy. - public WebServer(int port, RoutingStrategy strategy = RoutingStrategy.Regex) - : this(new[] { $"http://*:{port}/" }, strategy) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// Default settings are Regex RoutingStrategy and EmbedIO HttpListenerMode. - /// - /// - /// urlPrefix must be specified as something similar to: http://localhost:9696/ - /// Please notice the ending slash. -- It is important. - /// - /// The URL prefix. - /// The strategy. - public WebServer(string urlPrefix, RoutingStrategy strategy = RoutingStrategy.Regex) - : this(new[] { urlPrefix }, strategy) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// Default settings are Regex RoutingStrategy and EmbedIO HttpListenerMode. - /// - /// - /// urlPrefixes must be specified as something similar to: http://localhost:9696/ - /// Please notice the ending slash. -- It is important. - /// - /// The URL prefix. - /// The routing strategy. - /// Argument urlPrefix must be specified. - public WebServer(string[] urlPrefixes, RoutingStrategy routingStrategy = RoutingStrategy.Regex) - : this(urlPrefixes, routingStrategy, HttpListenerFactory.Create(HttpListenerMode.EmbedIO)) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// The URL prefix. - /// The routing strategy. - /// The mode. - /// Argument urlPrefix must be specified. - /// - /// urlPrefixes must be specified as something similar to: http://localhost:9696/ - /// Please notice the ending slash. -- It is important. - /// - public WebServer(string[] urlPrefixes, RoutingStrategy routingStrategy, HttpListenerMode mode) - : this(urlPrefixes, routingStrategy, HttpListenerFactory.Create(mode)) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// The URL prefix. - /// The routing strategy. - /// The mode. - /// The certificate. - /// Argument urlPrefix must be specified. - /// - /// urlPrefixes must be specified as something similar to: http://localhost:9696/ - /// Please notice the ending slash. -- It is important. - /// - public WebServer(string[] urlPrefixes, RoutingStrategy routingStrategy, HttpListenerMode mode, X509Certificate certificate) - : this(urlPrefixes, routingStrategy, HttpListenerFactory.Create(mode, certificate)) - { - // placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// The WebServer options. - public WebServer(WebServerOptions options) - : this(options.UrlPrefixes, options.RoutingStrategy, HttpListenerFactory.Create(options.Mode, options.Certificate)) - { - // temp placeholder - } - - /// - /// Initializes a new instance of the class. - /// - /// The URL prefix. - /// The routing strategy. - /// The HTTP listener. - /// Argument urlPrefix must be specified. - /// - /// urlPrefixes must be specified as something similar to: http://localhost:9696/ - /// Please notice the ending slash. -- It is important. - /// - public WebServer(string[] urlPrefixes, RoutingStrategy routingStrategy, IHttpListener httpListener) - { - if (urlPrefixes == null || urlPrefixes.Length <= 0) - throw new ArgumentException("At least 1 URL prefix in urlPrefixes must be specified"); - - $"Running HTTPListener: {httpListener.Name}".Info(nameof(WebServer)); - - RoutingStrategy = routingStrategy; - - if (RoutingStrategy == RoutingStrategy.Wildcard) - "Wilcard routing will be dropped in the next major version of EmbedIO. We advise to use Regex only".Debug(nameof(WebServer)); - - Listener = httpListener; - - foreach (var prefix in urlPrefixes) - { - var urlPrefix = new string(prefix?.ToCharArray()); - - if (urlPrefix.EndsWith("/") == false) urlPrefix = urlPrefix + "/"; - urlPrefix = urlPrefix.ToLowerInvariant(); - - Listener.AddPrefix(urlPrefix); - $"Web server prefix '{urlPrefix}' added.".Info(nameof(WebServer)); - } - - "Finished Loading Web Server.".Info(nameof(WebServer)); - } - - /// - public event WebServerStateChangedEventHandler StateChanged; - - /// - public Func> OnMethodNotAllowed { get; set; } = ctx => - ctx.HtmlResponseAsync(Responses.Response405Html, System.Net.HttpStatusCode.MethodNotAllowed); - - /// - public Func> OnNotFound { get; set; } = ctx => - ctx.HtmlResponseAsync(Responses.Response404Html, System.Net.HttpStatusCode.NotFound); - - /// - public Func> UnhandledException { get; set; } - - /// - /// Gets the underlying HTTP listener. - /// - /// - /// The listener. - /// - public IHttpListener Listener { get; protected set; } - - /// - /// Gets the URL Prefix for which the server is serving requests. - /// - /// - /// The URL prefix. - /// - public List UrlPrefixes => Listener.Prefixes; - - /// - public ReadOnlyCollection Modules => _modules.Modules; - - /// - public ISessionWebModule SessionModule => _modules.SessionModule; - - /// - public RoutingStrategy RoutingStrategy { get; protected set; } - - /// - public WebServerState State - { - get => _state; - private set - { - if (value == _state) return; - - var newState = value; - var oldState = _state; - _state = value; - - StateChanged?.Invoke(this, new WebServerStateChangedEventArgs(oldState, newState)); - } - } - - /// - /// Static method to create webserver instance using a single URL prefix. - /// - /// The URL prefix. - /// The webserver instance. - public static WebServer Create(string urlPrefix) => new WebServer(urlPrefix); - - /// - public T Module() - where T : class, IWebModule - { - return _modules.Module(); - } - - /// - public void RegisterModule(IWebModule webModule) => _modules.RegisterModule(webModule, this); - - /// - public void UnregisterModule(Type moduleType) => _modules.UnregisterModule(moduleType); - - /// - /// The method was already called. - /// Cancellation was requested. - /// - /// Both the server and client requests are queued separately on the thread pool, - /// so it is safe to call in a synchronous method. - /// - public async Task RunAsync(CancellationToken ct = default) - { - if (State == WebServerState.Loading || State == WebServerState.Listening) - throw new InvalidOperationException("The method was already called."); - - State = WebServerState.Loading; - Listener.IgnoreWriteExceptions = true; - Listener.Start(); - - "Started HTTP Listener".Info(nameof(WebServer)); - - // close port when the cancellation token is cancelled - ct.Register(() => Listener?.Stop()); - - try - { - // Init modules - _modules.StartModules(this, ct); - - State = WebServerState.Listening; - - // Disposing the web server will close the listener. - while (Listener != null && Listener.IsListening && !ct.IsCancellationRequested) - { - try - { - var clientSocket = await Listener.GetContextAsync(ct).ConfigureAwait(false); - - if (ct.IsCancellationRequested) - return; - - clientSocket.WebServer = this; - -#pragma warning disable CS4014 - var handler = new HttpHandler(clientSocket); - - handler.HandleClientRequest(ct); -#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - } - catch (Exception ex) - { - Listener?.Dispose(); - - if (ex is OperationCanceledException || ex is ObjectDisposedException || - ex is HttpListenerException) - { - if (!ct.IsCancellationRequested) - throw; - - return; - } - - ex.Log(nameof(WebServer)); - } - } - } - catch (TaskCanceledException) - { - // Ignore - } - finally - { - "Cleaning up".Info(nameof(WebServer)); - State = WebServerState.Stopped; - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - /// - protected virtual void Dispose(bool disposing) - { - if (!disposing || Listener == null) return; - - try - { - Listener.Dispose(); - } - finally - { - Listener = null; - } - - "Listener Closed.".Info(nameof(WebServer)); - - _modules.Dispose(); - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/WebServerOptions.cs b/src/Unosquare.Labs.EmbedIO/WebServerOptions.cs deleted file mode 100644 index a4ccd8330..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebServerOptions.cs +++ /dev/null @@ -1,278 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using Constants; - using System.Text; - using Swan; - using System; - using System.Diagnostics; - using System.Linq; - using System.Security.Cryptography.X509Certificates; - using System.Text.RegularExpressions; - - /// - /// Options for WebServer creation. - /// - public sealed class WebServerOptions - { - private X509Certificate2 _certificate; - - /// - /// Initializes a new instance of the class. - /// - /// The URL prefix. - public WebServerOptions(string urlPrefix) - : this(new[] { urlPrefix }) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The urls. - public WebServerOptions(string[] urlPrefixes) - { - UrlPrefixes = urlPrefixes; - } - - /// - /// Gets the URL prefixes. - /// - /// - /// The URL prefixes. - /// - public string[] UrlPrefixes { get; } - - /// - /// Gets or sets the routing strategy. - /// - /// - /// The routing strategy. - /// - public RoutingStrategy RoutingStrategy { get; set; } = RoutingStrategy.Regex; - - /// - /// Gets or sets the mode. - /// - /// - /// The mode. - /// - public HttpListenerMode Mode { get; set; } = HttpListenerMode.EmbedIO; - - /// - /// Gets or sets the certificate. - /// - /// - /// The certificate. - /// - public X509Certificate2 Certificate - { - get - { - if (AutoRegisterCertificate) - { - return TryRegisterCertificate() ? _certificate : null; - } - - return _certificate == null && AutoLoadCertificate ? LoadCertificate() : _certificate; - } - - set => _certificate = value; - } - - /// - /// Gets or sets the certificate thumb. - /// - /// - /// The certificate thumb. - /// - public string CertificateThumb { get; set; } - - /// - /// Gets or sets a value indicating whether [automatic load certificate]. - /// - /// - /// true if [automatic load certificate]; otherwise, false. - /// - public bool AutoLoadCertificate { get; set; } - - /// - /// Gets or sets a value indicating whether [automatic register certificate]. - /// - /// - /// true if [automatic register certificate]; otherwise, false. - /// - public bool AutoRegisterCertificate { get; set; } - - /// - /// Gets or sets the name of the store. - /// - /// - /// The name of the store. - /// - public StoreName StoreName { get; set; } = StoreName.My; - - /// - /// Gets or sets the store location. - /// - /// - /// The store location. - /// - public StoreLocation StoreLocation { get; set; } = StoreLocation.LocalMachine; - - private X509Certificate2 LoadCertificate() - { - if (!string.IsNullOrWhiteSpace(CertificateThumb)) return GetCertificate(); - - if (Runtime.OS != Swan.OperatingSystem.Windows) - throw new InvalidOperationException("AutoLoad functionality is only available in Windows"); - - var netsh = GetNetsh("show"); - - var thumbPrint = string.Empty; - - netsh.ErrorDataReceived += (s, e) => - { - if (string.IsNullOrWhiteSpace(e.Data)) return; - - e.Data.Error(nameof(netsh)); - }; - - netsh.OutputDataReceived += (s, e) => - { - if (string.IsNullOrWhiteSpace(e.Data)) return; - - e.Data.Debug(nameof(netsh)); - - var line = e.Data.Trim(); - - if (line.StartsWith("Certificate Hash") && line.IndexOf(":", StringComparison.Ordinal) > -1) - thumbPrint = line.Split(':')[1].Trim(); - }; - - if (netsh.Start()) - { - netsh.BeginOutputReadLine(); - netsh.BeginErrorReadLine(); - - netsh.WaitForExit(); - - if (netsh.ExitCode == 0 && !string.IsNullOrWhiteSpace(thumbPrint)) - { - return GetCertificate(thumbPrint); - } - } - - return null; - } - - private X509Certificate2 GetCertificate(string thumb = null) - { - // strip any non-hexadecimal values and make uppercase - var thumbprint = Regex.Replace(thumb ?? CertificateThumb, @"[^\da-fA-F]", string.Empty).ToUpper(); - var store = new X509Store(StoreName, StoreLocation); - - try - { - store.Open(OpenFlags.ReadOnly); - - var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); - - return signingCert.Count == 0 ? null : signingCert[0]; - } - finally - { - store.Close(); - } - } - - private bool AddCertificateToStore() - { - var store = new X509Store(StoreName, StoreLocation); - - try - { - store.Open(OpenFlags.ReadWrite); - store.Add(_certificate); - } - catch - { - return false; - } - finally - { - store.Close(); - } - - return true; - } - - private bool TryRegisterCertificate() - { - if (Runtime.OS != Swan.OperatingSystem.Windows) - throw new InvalidOperationException("AutoRegister functionality is only available in Windows"); - - if (_certificate == null) - throw new InvalidOperationException("A certificate is required to AutoRegister"); - - if (GetCertificate(_certificate.Thumbprint) == null && !AddCertificateToStore()) - { - throw new InvalidOperationException( - "The provided certificate cannot be added to the default store, add it manually"); - } - - var netsh = GetNetsh("add", $"certhash={_certificate.Thumbprint} appid={{adaa04bb-8b63-4073-a12f-d6f8c0b4383f}}"); - - var sb = new StringBuilder(); - - void PushLine(object sender, DataReceivedEventArgs e) - { - if (string.IsNullOrWhiteSpace(e.Data)) return; - - sb.AppendLine(e.Data); - e.Data.Error(nameof(netsh)); - } - - netsh.OutputDataReceived += PushLine; - - netsh.ErrorDataReceived += PushLine; - - if (!netsh.Start()) return false; - - netsh.BeginOutputReadLine(); - netsh.BeginErrorReadLine(); - netsh.WaitForExit(); - - return netsh.ExitCode == 0 ? true : throw new InvalidOperationException($"Netsh error: {sb}"); - } - - private int GetSslPort() - { - var port = 443; - - foreach (var url in UrlPrefixes.Where(x => - x.StartsWith("https", StringComparison.InvariantCultureIgnoreCase))) - { - var match = Regex.Match(url, @":(\d+)"); - - if (match.Success && int.TryParse(match.Groups[1].Value, out port)) - break; - } - - return port; - } - - private Process GetNetsh(string verb, string options = "") => new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "netsh", - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - Arguments = - $"http {verb} sslcert ipport=0.0.0.0:{GetSslPort()} {options}", - }, - }; - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/WebSocket.cs b/src/Unosquare.Labs.EmbedIO/WebSocket.cs deleted file mode 100644 index 423e4ec7d..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebSocket.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System.Threading; - using System; - using System.Net.WebSockets; - using System.Threading.Tasks; - - /// - /// Represents a wrapper around a regular WebSocketContext. - /// - /// - public class WebSocket : IWebSocket - { - /// - /// Initializes a new instance of the class. - /// - /// The web socket. - public WebSocket(System.Net.WebSockets.WebSocket webSocket) - { - SystemWebSocket = webSocket; - } - - /// - /// Gets the real WebSocket object from System.Net. - /// - /// - /// The system web socket. - /// - public System.Net.WebSockets.WebSocket SystemWebSocket { get; } - - /// - public Net.WebSocketState State - { - get - { - switch (SystemWebSocket.State) - { - case WebSocketState.Connecting: - return Net.WebSocketState.Connecting; - case WebSocketState.Open: - return Net.WebSocketState.Open; - default: - return Net.WebSocketState.Closed; - } - } - } - - /// - void IDisposable.Dispose() => SystemWebSocket?.Dispose(); - - /// - public Task SendAsync(byte[] buffer, bool isText, CancellationToken cancellationToken = default) - => SystemWebSocket.SendAsync( - new ArraySegment(buffer), - isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary, - true, - cancellationToken); - - /// - public Task CloseAsync(CancellationToken cancellationToken = default) => - SystemWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken); - - /// - public Task CloseAsync(Net.CloseStatusCode code, string comment = null, CancellationToken cancellationToken = default)=> - SystemWebSocket.CloseAsync(MapCloseStatus(code), comment ?? string.Empty, cancellationToken); - - private WebSocketCloseStatus MapCloseStatus(Net.CloseStatusCode code) - { - switch (code) - { - case Net.CloseStatusCode.Normal: - return WebSocketCloseStatus.NormalClosure; - case Net.CloseStatusCode.ProtocolError: - return WebSocketCloseStatus.ProtocolError; - case Net.CloseStatusCode.InvalidData: - case Net.CloseStatusCode.UnsupportedData: - return WebSocketCloseStatus.InvalidPayloadData; - case Net.CloseStatusCode.PolicyViolation: - return WebSocketCloseStatus.PolicyViolation; - case Net.CloseStatusCode.TooBig: - return WebSocketCloseStatus.MessageTooBig; - case Net.CloseStatusCode.MandatoryExtension: - return WebSocketCloseStatus.MandatoryExtension; - case Net.CloseStatusCode.ServerError: - return WebSocketCloseStatus.InternalServerError; - default: - throw new ArgumentOutOfRangeException(nameof(code), code, null); - } - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/WebSocketContext.cs b/src/Unosquare.Labs.EmbedIO/WebSocketContext.cs deleted file mode 100644 index 56e4686e9..000000000 --- a/src/Unosquare.Labs.EmbedIO/WebSocketContext.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Unosquare.Labs.EmbedIO -{ - using System; - - /// - /// Represents a wrapper around a regular WebSocketContext. - /// - public class WebSocketContext : IWebSocketContext - { - private readonly System.Net.WebSockets.HttpListenerWebSocketContext _webSocketContext; - - /// - /// Initializes a new instance of the class. - /// - /// The web socket context. - public WebSocketContext(System.Net.WebSockets.HttpListenerWebSocketContext webSocketContext) - { - _webSocketContext = webSocketContext; - WebSocket = new WebSocket(_webSocketContext.WebSocket); - CookieCollection = new CookieCollection(_webSocketContext.CookieCollection); - } - - /// - public IWebSocket WebSocket { get; } - - /// - public ICookieCollection CookieCollection { get; } - - /// - public Uri RequestUri => _webSocketContext.RequestUri; - } -} \ No newline at end of file diff --git a/test/EmbedIO.Tests/ActionModuleTest.cs b/test/EmbedIO.Tests/ActionModuleTest.cs new file mode 100644 index 000000000..6b23b89cf --- /dev/null +++ b/test/EmbedIO.Tests/ActionModuleTest.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Testing; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class ActionModuleTest + { + private const string Ok = "Ok"; + + [Test] + public Task OnAny_ResponseOK() + { + void Configure(IWebServer server) => server + .OnAny(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.GetAsync("/").ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnGet_ResponseOK() + { + void Configure(IWebServer server) => server + .OnGet(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.GetAsync("/").ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnPost_ResponseOK() + { + void Configure(IWebServer server) => server + .OnPost(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.PostAsync("/", null).ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnPut_ResponseOK() + { + void Configure(IWebServer server) => server + .OnPut(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.PutAsync("/", null).ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnHead_ResponseOK() + { + void Configure(IWebServer server) => server + .OnHead(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.HeadAsync("/").ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnDelete_ResponseOK() + { + void Configure(IWebServer server) => server + .OnDelete(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.DeleteAsync("/").ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnOptions_ResponseOK() + { + void Configure(IWebServer server) => server + .OnOptions(ctx=> ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.OptionsAsync("/").ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + + [Test] + public Task OnPatch_ResponseOK() + { + void Configure(IWebServer server) => server + .OnPatch(ctx => ctx.SendStringAsync(Ok, MimeType.PlainText, Encoding.UTF8)); + + async Task Use(HttpClient client) + { + using (var response = await client.PatchAsync("/", null).ConfigureAwait(false)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.AreEqual(Ok, responseString); + } + } + + return TestWebServer.UseAsync(Configure, Use); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/BasicAuthenticationModuleTest.cs b/test/EmbedIO.Tests/BasicAuthenticationModuleTest.cs new file mode 100644 index 000000000..2637d1d3a --- /dev/null +++ b/test/EmbedIO.Tests/BasicAuthenticationModuleTest.cs @@ -0,0 +1,69 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Authentication; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class BasicAuthenticationModuleTest : EndToEndFixtureBase + { + private const string UserName = "root"; + private const string Password = "password1234"; + + public BasicAuthenticationModuleTest() + : base(true) + { + } + + protected override void OnSetUp() + { + Server + .WithModule(new BasicAuthenticationModule("/").WithAccount(UserName, Password)) + .OnAny(ctx => + { + ctx.Response.SetEmptyResponse((int)HttpStatusCode.OK); + return Task.FromResult(true); + }); + } + + [Test] + public async Task RequestWithValidCredentials_ReturnsOK() + { + var response = await MakeRequest(UserName, Password).ConfigureAwait(false); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + } + + [Test] + public async Task RequestWithInvalidCredentials_ReturnsUnauthorized() + { + const string wrongPassword = "wrongpaassword"; + + var response = await MakeRequest(UserName, wrongPassword).ConfigureAwait(false); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode, "Status Code Unauthorized"); + } + + [Test] + public async Task RequestWithNoAuthorizationHeader_ReturnsUnauthorized() + { + var response = await MakeRequest(null, null).ConfigureAwait(false); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode, "Status Code Unauthorized"); + } + + private Task MakeRequest(string userName, string password) + { + var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + + if (userName == null) return Client.SendAsync(request); + + var encodedCredentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{userName}:{password}")); + var authHeaderValue = new System.Net.Http.Headers.AuthenticationHeaderValue("basic", encodedCredentials); + request.Headers.Add("Authorization", authHeaderValue.ToString()); + + return Client.SendAsync(request); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/ContentEncodingNegotiationTest.cs b/test/EmbedIO.Tests/ContentEncodingNegotiationTest.cs new file mode 100644 index 000000000..554ae1163 --- /dev/null +++ b/test/EmbedIO.Tests/ContentEncodingNegotiationTest.cs @@ -0,0 +1,33 @@ +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class ContentEncodingNegotiationTest + { + [TestCase("identity;q=1, *;q=0", true, CompressionMethod.None, CompressionMethodNames.None)] + [TestCase("identity;q=1, *;q=0", false, CompressionMethod.None, CompressionMethodNames.None)] + public void ContentEncodingNegotiation_Succeeds( + string requestHeaders, + bool preferCompression, + CompressionMethod expectedCompressionMethod, + string expectedCompressionMethodName) + { + var list = new QValueList(true, requestHeaders); + var negotiated = list.TryNegotiateContentEncoding(preferCompression, out var actualCompressionMethod, out var actualCompressionMethodName); + Assert.AreEqual(true, negotiated); + Assert.AreEqual(expectedCompressionMethod, actualCompressionMethod); + Assert.AreEqual(expectedCompressionMethodName, actualCompressionMethodName); + } + + [TestCase("*;q=0", true)] + [TestCase("*;q=0", false)] + public void ContentEncodingNegotiation_Fails(string requestHeaders, bool preferCompression) + { + var list = new QValueList(true, requestHeaders); + var negotiated = list.TryNegotiateContentEncoding(preferCompression, out var actualCompressionMethod, out var actualCompressionMethodName); + Assert.AreEqual(false, negotiated); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/CorsModuleTest.cs b/test/EmbedIO.Tests/CorsModuleTest.cs new file mode 100644 index 000000000..a00593bd8 --- /dev/null +++ b/test/EmbedIO.Tests/CorsModuleTest.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class CorsModuleTest : EndToEndFixtureBase + { + public CorsModuleTest() + : base(true) + { + } + + protected override void OnSetUp() + { + Server + .WithCors( + "http://client.cors-api.appspot.com,http://unosquare.github.io,http://run.plnkr.co", + "content-type", + "post,get") + .WithWebApi("/api", m => m.RegisterController()); + } + + [Test] + public async Task RequestOptionsVerb_ReturnsOK() + { + var request = new HttpRequestMessage(HttpMethod.Options, $"{WebServerUrl}/api/empty"); + + request.Headers.Add(HttpHeaderNames.Origin, "http://unosquare.github.io"); + request.Headers.Add(HttpHeaderNames.AccessControlRequestMethod, "post"); + request.Headers.Add(HttpHeaderNames.AccessControlRequestHeaders, "content-type"); + + var response = await Client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/DirectoryBrowserTest.cs b/test/EmbedIO.Tests/DirectoryBrowserTest.cs new file mode 100644 index 000000000..59d58f1cd --- /dev/null +++ b/test/EmbedIO.Tests/DirectoryBrowserTest.cs @@ -0,0 +1,58 @@ +using System.Threading.Tasks; +using EmbedIO.Files; +using EmbedIO.Tests.TestObjects; +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class DirectoryBrowserTest : EndToEndFixtureBase + { + public DirectoryBrowserTest() + : base(false) + { + ServedFolder = new StaticFolder.WithHtmlFiles(nameof(DirectoryBrowserTest)); + } + + protected StaticFolder.WithHtmlFiles ServedFolder { get; } + + protected override void OnSetUp() + { + Server + .WithStaticFolder("/", StaticFolder.RootPathOf(nameof(DirectoryBrowserTest)), true, m => m + .WithDirectoryLister(DirectoryLister.Html) + .WithoutDefaultDocument()); + } + + protected override void Dispose(bool disposing) + { + ServedFolder.Dispose(); + } + + public class Browse : DirectoryBrowserTest + { + [Test] + public async Task Root_ReturnsFilesList() + { + var htmlContent = await Client.GetStringAsync(UrlPath.Root); + + Assert.IsNotEmpty(htmlContent); + + foreach (var file in StaticFolder.WithHtmlFiles.RandomHtmls) + Assert.IsTrue(htmlContent.Contains(file)); + } + + [Test] + public async Task Subfolder_ReturnsFilesList() + { + var htmlContent = await Client.GetStringAsync("/sub"); + + Assert.IsNotEmpty(htmlContent); + + foreach (var file in StaticFolder.WithHtmlFiles.RandomHtmls) + Assert.IsTrue(htmlContent.Contains(file)); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/EmbedIO.Tests.csproj b/test/EmbedIO.Tests/EmbedIO.Tests.csproj new file mode 100644 index 000000000..5f8003139 --- /dev/null +++ b/test/EmbedIO.Tests/EmbedIO.Tests.csproj @@ -0,0 +1,28 @@ + + + + Copyright (c) 2016-2019 - Unosquare + netcoreapp2.2 + UnitTest + ..\..\StyleCop.Analyzers.ruleset + 7.3 + + + + + all + runtime; build; native; contentfiles; analyzers + + + All + + + + + + + + + + + diff --git a/test/EmbedIO.Tests/EndToEndFixtureBase.cs b/test/EmbedIO.Tests/EndToEndFixtureBase.cs new file mode 100644 index 000000000..bf10448ed --- /dev/null +++ b/test/EmbedIO.Tests/EndToEndFixtureBase.cs @@ -0,0 +1,82 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.Testing; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using Swan; + +namespace EmbedIO.Tests +{ + public abstract class EndToEndFixtureBase : IDisposable + { + private readonly bool _useTestWebServer; + + protected EndToEndFixtureBase(bool useTestWebServer) + { + // Terminal.Settings.GlobalLoggingMessageType = LogMessageType.None; + + _useTestWebServer = useTestWebServer; + } + + ~EndToEndFixtureBase() + { + Dispose(false); + } + + protected string WebServerUrl { get; private set; } + + protected TestHttpClient Client { get; private set; } + + protected IWebServer Server { get; set; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [SetUp] + public void SetUp() + { + WebServerUrl = Resources.GetServerAddress(); + if (_useTestWebServer) + { + var testWebServer = new TestWebServer(WebServerUrl); + Server = testWebServer; + Client = testWebServer.Client; + } + else + { + Server = new WebServer(WebServerUrl); + Client = TestHttpClient.Create(WebServerUrl); + } + + OnSetUp(); + Server.Start(); + } + + [TearDown] + public void TearDown() + { + Task.Delay(500).Await(); + Server?.Dispose(); + OnTearDown(); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + + Client?.Dispose(); + Server?.Dispose(); + } + + protected virtual void OnSetUp() + { + } + + protected virtual void OnTearDown() + { + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/ExceptionHandlingTest.cs b/test/EmbedIO.Tests/ExceptionHandlingTest.cs new file mode 100644 index 000000000..4545fb4cc --- /dev/null +++ b/test/EmbedIO.Tests/ExceptionHandlingTest.cs @@ -0,0 +1,106 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using EmbedIO.Utilities; +using NUnit.Framework; +using Swan; + +namespace EmbedIO.Tests +{ + public class ExceptionHandlingTest : EndToEndFixtureBase + { + const HttpStatusCode HttpExceptionStatusCode = HttpStatusCode.GatewayTimeout; + + private readonly string ExceptionMessage = Guid.NewGuid().ToString(); + private readonly string SecondLevelExceptionMessage = Guid.NewGuid().ToString(); + + public ExceptionHandlingTest() + : base(true) + { + } + + public class Unhandled_FirstLevel : ExceptionHandlingTest + { + protected override void OnSetUp() + { + Server + .OnAny(_ => throw new Exception(ExceptionMessage)) + .HandleUnhandledException(ExceptionHandler.EmptyResponseWithHeaders); + } + + [Test] + public async Task UnhandledException_ResponseIsAsExpected() + { + var response = await Client.GetAsync(UrlPath.Root); + + Assert.IsNotNull(response); + Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode); + CollectionAssert.AreEqual( + new[] { nameof(Exception) }, + response.Headers.GetValues(ExceptionHandler.ExceptionTypeHeaderName)); + + CollectionAssert.AreEqual( + new[] { ExceptionMessage }, + response.Headers.GetValues(ExceptionHandler.ExceptionMessageHeaderName)); + } + } + + public class Unhandled_SecondLevel : ExceptionHandlingTest + { + protected override void OnSetUp() + { + Server + .OnAny(_ => throw new Exception(ExceptionMessage)) + .HandleUnhandledException((ctx, ex) => throw new Exception(SecondLevelExceptionMessage)); + } + + [Test] + public void SecondLevelException_ServerDoesNotCrash() + { + // When using a TestWebServer, context handling code is called by the client; + // hence, an unhandled second-level exception would be seen here. + Assert.DoesNotThrow(() => Client.GetAsync(UrlPath.Root).Await()); + } + } + + public class Http_FirstLevel : ExceptionHandlingTest + { + protected override void OnSetUp() + { + Server + .OnAny(_ => throw new HttpException(HttpExceptionStatusCode, ExceptionMessage)) + .HandleHttpException(HttpExceptionHandler.PlainTextResponse); + } + + [Test] + public async Task HttpException_ResponseIsAsExpected() + { + var response = await Client.GetAsync(UrlPath.Root); + + Assert.IsNotNull(response); + Assert.AreEqual(HttpExceptionStatusCode, response.StatusCode); + Assert.AreEqual( + ExceptionMessage, + await response.Content.ReadAsStringAsync()); + } + + public class Http_SecondLevel : ExceptionHandlingTest + { + protected override void OnSetUp() + { + Server + .OnAny(_ => throw new HttpException(HttpExceptionStatusCode, ExceptionMessage)) + .HandleUnhandledException((ctx, ex) => throw new Exception(SecondLevelExceptionMessage)); + } + + [Test] + public void SecondLevelException_ServerDoesNotCrash() + { + // When using a TestWebServer, context handling code is called by the client; + // hence, an unhandled second-level exception would be seen here. + Assert.DoesNotThrow(() => Client.GetAsync(UrlPath.Root).Await()); + } + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/FluentTest.cs b/test/EmbedIO.Tests/FluentTest.cs new file mode 100644 index 000000000..577bf9faa --- /dev/null +++ b/test/EmbedIO.Tests/FluentTest.cs @@ -0,0 +1,62 @@ +using EmbedIO.Files; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using System; +using System.Linq; +using Swan; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class FluentTest + { + private readonly WebServer _nullWebServer = null; + private string _rootPath; + private string _webServerUrl; + + [SetUp] + public void Init() + { + // Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; + + _webServerUrl = Resources.GetServerAddress(); + _rootPath = StaticFolder.RootPathOf(nameof(FluentTest)); + } + + [Test] + public void FluentWithStaticFolder() + { + var webServer = new WebServer(_webServerUrl) + .WithLocalSessionManager() + .WithStaticFolder("/", _rootPath, true); + + Assert.AreEqual(webServer.Modules.Count, 1, "It has 1 modules loaded"); + Assert.IsNotNull(webServer.Modules.OfType().FirstOrDefault(), $"It has {nameof(FileModule)}"); + } + + [Test] + public void FluentWithStaticFolderArgumentException() + { + Assert.Throws(() => + _nullWebServer.WithStaticFolder("/", StaticFolder.RootPathOf(nameof(FluentWithStaticFolderArgumentException)), true)); + } + + [Test] + public void FluentWithLocalSessionManagerWebServerNull_ThrowsArgumentException() + { + Assert.Throws(() => _nullWebServer.WithLocalSessionManager()); + } + + [Test] + public void FluentWithWebApiArgumentException() + { + Assert.Throws(() => _nullWebServer.WithWebApi("/", null)); + } + + [Test] + public void FluentWithCorsArgumentException() + { + Assert.Throws(() => _nullWebServer.WithCors()); + } + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/GlobalSuppressions.cs b/test/EmbedIO.Tests/GlobalSuppressions.cs similarity index 83% rename from test/Unosquare.Labs.EmbedIO.Tests/GlobalSuppressions.cs rename to test/EmbedIO.Tests/GlobalSuppressions.cs index c460c73d5..99058ae98 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/GlobalSuppressions.cs +++ b/test/EmbedIO.Tests/GlobalSuppressions.cs @@ -1,4 +1,4 @@ #pragma warning disable SA1652 // Enable XML documentation output -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Testing", Scope = "namespace", Target = "~N:Unosquare.Labs.EmbedIO.Tests")] -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Testing", Scope = "namespace", Target = "~N:Unosquare.Labs.EmbedIO.Tests.TestObjects")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Testing", Scope = "namespace", Target = "~N:EmbedIO.Tests")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Testing", Scope = "namespace", Target = "~N:EmbedIO.Tests.TestObjects")] #pragma warning restore SA1652 // Enable XML documentation output \ No newline at end of file diff --git a/test/EmbedIO.Tests/HttpsTest.cs b/test/EmbedIO.Tests/HttpsTest.cs new file mode 100644 index 000000000..424904ec6 --- /dev/null +++ b/test/EmbedIO.Tests/HttpsTest.cs @@ -0,0 +1,100 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using Swan; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class HttpsTest + { + private const string DefaultMessage = "HOLA"; + private const string HttpsUrl = "https://localhost:5555"; + + [Test] + public async Task OpenWebServerHttps_RetrievesIndex() + { + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) + Assert.Ignore("Only Windows"); + + ServicePointManager.ServerCertificateValidationCallback = ValidateCertificate; + + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithAutoLoadCertificate() + .WithMode(HttpListenerMode.EmbedIO); + + using (var webServer = new WebServer(options)) + { + webServer.OnAny(ctx => ctx.SendStringAsync(DefaultMessage, MimeType.PlainText, Encoding.UTF8)); + + var dump = webServer.RunAsync(); + + using (var httpClientHandler = new HttpClientHandler()) + { + httpClientHandler.ServerCertificateCustomValidationCallback = ValidateCertificate; + using (var httpClient = new HttpClient(httpClientHandler)) + { + Assert.AreEqual(DefaultMessage, await httpClient.GetStringAsync(HttpsUrl)); + } + } + } + } + + [Test] + public void OpenWebServerHttpsWithLinuxOrMac_ThrowsInvalidOperation() + { + if (SwanRuntime.OS == Swan.OperatingSystem.Windows) + Assert.Ignore("Ignore Windows"); + + Assert.Throws(() => { + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithAutoLoadCertificate(); + + new WebServer(options).Void(); + }); + } + + [Test] + public void OpenWebServerHttpsWithoutCert_ThrowsInvalidOperation() + { + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) + Assert.Ignore("Only Windows"); + + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithAutoRegisterCertificate(); + + Assert.Throws(() => new WebServer(options).Void()); + } + + [Test] + public void OpenWebServerHttpsWithInvalidStore_ThrowsInvalidOperation() + { + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) + Assert.Ignore("Only Windows"); + + var options = new WebServerOptions() + .WithUrlPrefix(HttpsUrl) + .WithCertificate(new X509Certificate2()) + .WithAutoRegisterCertificate(); + + Assert.Throws(() => new WebServer(options)); + } + + // Bypass certificate validation. + private static bool ValidateCertificate( + object sender, + X509Certificate certificate, + X509Chain chain, + SslPolicyErrors sslPolicyErrors) + => true; + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/IPv6Test.cs b/test/EmbedIO.Tests/IPv6Test.cs similarity index 52% rename from test/Unosquare.Labs.EmbedIO.Tests/IPv6Test.cs rename to test/EmbedIO.Tests/IPv6Test.cs index ad7bc2aaf..65932ca47 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/IPv6Test.cs +++ b/test/EmbedIO.Tests/IPv6Test.cs @@ -1,12 +1,12 @@ -namespace Unosquare.Labs.EmbedIO.Tests +using System; +using System.Net.Http; +using System.Threading.Tasks; +using EmbedIO.Net; +using NUnit.Framework; +using Swan; + +namespace EmbedIO.Tests { - using Net; - using NUnit.Framework; - using Swan; - using System; - using System.Net.Http; - using System.Threading.Tasks; - [TestFixture] public class IPv6Test { @@ -15,20 +15,20 @@ public void Setup() { EndPointManager.UseIpv6 = true; - Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; + // Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; } [TestCase("http://[::1]:8877")] [TestCase("http://127.0.0.1:8877")] public async Task WithUseIpv6_ReturnsValid(string urlTest) { - if (Runtime.OS != Swan.OperatingSystem.Windows) + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) Assert.Ignore("Only Windows"); - var instance = new WebServer(new[] { "http://*:8877" }, Constants.RoutingStrategy.Regex, HttpListenerMode.EmbedIO); - instance.OnAny((ctx, ct) => ctx.JsonResponseAsync(DateTime.Now, ct)); + var instance = new WebServer(HttpListenerMode.EmbedIO, "http://*:8877"); + instance.OnAny(ctx => ctx.SendDataAsync(DateTime.Now)); - instance.RunAsync(); + var dump = instance.RunAsync(); using (var client = new HttpClient()) { @@ -39,13 +39,13 @@ public async Task WithUseIpv6_ReturnsValid(string urlTest) [Test] public async Task WithIpv6Loopback_ReturnsValid() { - if (Runtime.OS != Swan.OperatingSystem.Windows) + if (SwanRuntime.OS != Swan.OperatingSystem.Windows) Assert.Ignore("Only Windows"); - var instance = new WebServer(new[] { "http://[::1]:8877" }, Constants.RoutingStrategy.Regex, HttpListenerMode.EmbedIO); - instance.OnAny((ctx, ct) => ctx.JsonResponseAsync(DateTime.Now, ct)); + var instance = new WebServer(HttpListenerMode.EmbedIO, "http://[::1]:8877"); + instance.OnAny(ctx => ctx.SendDataAsync(DateTime.Now)); - instance.RunAsync(); + var dump = instance.RunAsync(); using (var client = new HttpClient()) { diff --git a/test/EmbedIO.Tests/IWebServerTest.cs b/test/EmbedIO.Tests/IWebServerTest.cs new file mode 100644 index 000000000..e6250a0da --- /dev/null +++ b/test/EmbedIO.Tests/IWebServerTest.cs @@ -0,0 +1,74 @@ +using EmbedIO.Sessions; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using System.Threading.Tasks; +using EmbedIO.Testing; +using Swan.Formatters; + +namespace EmbedIO.Tests +{ + public class IWebServerTest + { + [Test] + public void SetupInMemoryWebServer_ReturnsValidInstance() + { + using (var webserver = new TestWebServer()) + { + Assert.IsNotNull(webserver); + } + } + + [Test] + public void AddModule_ReturnsValidInstance() + { + using (var webserver = new TestWebServer()) + { + webserver.WithCors(); + + Assert.AreEqual(1, webserver.Modules.Count); + } + } + + [Test] + public void SetSessionManager_ReturnsValidInstance() + { + using (var webserver = new TestWebServer()) + { + webserver.SessionManager = new LocalSessionManager(); + + Assert.NotNull(webserver.SessionManager); + } + } + + [Test] + public void SetSessionManagerToNull_ReturnsValidInstance() + { + using (var webserver = new TestWebServer()) + { + webserver.SessionManager = new LocalSessionManager(); + webserver.SessionManager = null; + + Assert.IsNull(webserver.SessionManager); + } + } + + [Test] + public async Task RunsServerAndRequestData_ReturnsValidData() + { + using (var server = new TestWebServer()) + { + server + .OnAny(ctx => ctx.SendDataAsync(new Person {Name = nameof(Person)})) + .Start(); + + var data = await server.Client.GetStringAsync("/").ConfigureAwait(false); + Assert.IsNotNull(data); + + var person = Json.Deserialize(data); + Assert.IsNotNull(person); + + Assert.AreEqual(person.Name, nameof(Person)); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/Issues/Issue318_StartupDeadlock.cs b/test/EmbedIO.Tests/Issues/Issue318_StartupDeadlock.cs new file mode 100644 index 000000000..72903949a --- /dev/null +++ b/test/EmbedIO.Tests/Issues/Issue318_StartupDeadlock.cs @@ -0,0 +1,24 @@ +using NUnit.Framework; + +namespace EmbedIO.Tests.Issues +{ + public class Issue318_StartupDeadlock + { + [TestCase(HttpListenerMode.EmbedIO)] + [TestCase(HttpListenerMode.Microsoft)] + public void WebServer_Start_OnListenerStartFailure_Returns(HttpListenerMode listenerMode) + { + void ConfigureServerOptions(WebServerOptions options) => options + .WithMode(listenerMode) + .WithUrlPrefix("http://*:12345"); + + using (var server1 = new WebServer(ConfigureServerOptions)) + using (var server2 = new WebServer(ConfigureServerOptions)) + { + server1.Start(); + server2.Start(); + Assert.AreEqual(WebServerState.Stopped, server2.State); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/Issues/Issue319_FileModuleDisposeException.cs b/test/EmbedIO.Tests/Issues/Issue319_FileModuleDisposeException.cs new file mode 100644 index 000000000..20d062e63 --- /dev/null +++ b/test/EmbedIO.Tests/Issues/Issue319_FileModuleDisposeException.cs @@ -0,0 +1,17 @@ +using EmbedIO.Files; +using EmbedIO.Testing; +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests.Issues +{ + public class Issue319_FileModuleDisposeException + { + [Test] + public void FileModule_Dispose_WhenNotStarted_DoesNotThrow() + { + var module = new FileModule(UrlPath.Root, new MockFileProvider()); + Assert.DoesNotThrow(() => module.Dispose()); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/Issues/Issue330_PreferCompressionFalse.cs b/test/EmbedIO.Tests/Issues/Issue330_PreferCompressionFalse.cs new file mode 100644 index 000000000..20ddebb36 --- /dev/null +++ b/test/EmbedIO.Tests/Issues/Issue330_PreferCompressionFalse.cs @@ -0,0 +1,31 @@ +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests.Issues +{ + public class Issue330_PreferCompressionFalse + { + [Test] + public void QValueList_TryNegotiateContentEncoding_WhenPreferCompressionFalse_OnNoCompressionSpecified_ReturnsTrue() + { + var list = new QValueList(true, "gzip, deflate"); + Assert.IsTrue(list.TryNegotiateContentEncoding(false, out _, out _)); + } + + [Test] + public void QValueList_TryNegotiateContentEncoding_WhenPreferCompressionFalse_OnNoCompressionSpecified_YieldsNone() + { + var list = new QValueList(true, "gzip, deflate"); + list.TryNegotiateContentEncoding(false, out var compressionMethod, out _); + Assert.AreEqual(CompressionMethod.None, compressionMethod); + } + + [Test] + public void QValueList_TryNegotiateContentEncoding_WhenPreferCompressionFalse_OnNoCompressionSpecified_YieldsIdentity() + { + var list = new QValueList(true, "gzip, deflate"); + list.TryNegotiateContentEncoding(false, out var _, out var name); + Assert.AreEqual(CompressionMethodNames.None, name); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/LocalSessionManagerTest.cs b/test/EmbedIO.Tests/LocalSessionManagerTest.cs new file mode 100644 index 000000000..5ad70f8e6 --- /dev/null +++ b/test/EmbedIO.Tests/LocalSessionManagerTest.cs @@ -0,0 +1,150 @@ +using EmbedIO.Sessions; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class LocalSessionManagerTest : EndToEndFixtureBase + { + public LocalSessionManagerTest() + : base(false) + { + } + + protected override void OnSetUp() + { + Server + .WithSessionManager(new LocalSessionManager { + SessionDuration = TimeSpan.FromSeconds(1), + }) + .WithWebApi("/api", m => m.RegisterController()) + .OnGet(ctx => + { + ctx.Session["data"] = true; + ctx.Response.SetEmptyResponse((int)HttpStatusCode.OK); + return Task.FromResult(true); + }); + } + + protected void ClearServerCookies() + { + foreach (var cookie in Client.CookieContainer.GetCookies(new Uri(WebServerUrl)).Cast()) + { + cookie.Expired = true; + } + } + + protected async Task ValidateCookie(HttpRequestMessage request) + { + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + } + + Assert.IsNotNull(Client.CookieContainer, "Cookies are not null"); + Assert.Greater( + Client.CookieContainer.GetCookies(new Uri(WebServerUrl)).Count, + 0, + "Cookies are not empty"); + } + + public class Sessions : LocalSessionManagerTest + { + [Test] + public async Task DeleteSession() + { + var request = new HttpRequestMessage(HttpMethod.Get, + WebServerUrl + TestLocalSessionController.PutData); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(TestLocalSessionController.MyData, body); + } + + request = new HttpRequestMessage(HttpMethod.Get, + WebServerUrl + TestLocalSessionController.DeleteSession); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual("Deleted", body); + } + + request = new HttpRequestMessage(HttpMethod.Get, + WebServerUrl + TestLocalSessionController.GetData); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(string.Empty, body); + } + } + + [Test] + public async Task GetDifferentSession() + { + var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + await ValidateCookie(request); + var firstCookie = Client.CookieContainer.GetCookieHeader(new Uri(WebServerUrl)); + + request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + await ValidateCookie(request); + Assert.AreEqual(firstCookie, Client.CookieContainer.GetCookieHeader(new Uri(WebServerUrl))); + + ClearServerCookies(); + + request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + await ValidateCookie(request); + Assert.AreNotEqual(firstCookie, Client.CookieContainer.GetCookieHeader(new Uri(WebServerUrl))); + } + } + + public class Cookies : LocalSessionManagerTest + { + [Test] + public async Task RetrieveCookie() + { + var request = new HttpRequestMessage(HttpMethod.Get, + WebServerUrl + TestLocalSessionController.GetCookie); + var uri = new Uri(WebServerUrl + TestLocalSessionController.GetCookie); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status OK"); + var responseCookies = Client.CookieContainer.GetCookies(uri).Cast(); + Assert.IsNotNull(responseCookies, "Cookies are not null"); + + Assert.Greater(responseCookies.Count(), 0, "Cookies are not empty"); + var cookieName = responseCookies.FirstOrDefault(c => c.Name == TestLocalSessionController.CookieName); + Assert.AreEqual(TestLocalSessionController.CookieName, cookieName?.Name); + } + } + + [Test] + public async Task GetCookie() + { + var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + await ValidateCookie(request); + Assert.IsNotEmpty( + Client.CookieContainer.GetCookieHeader(new Uri(WebServerUrl)), + "Cookie content is not null"); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/MimeTypeTest.cs b/test/EmbedIO.Tests/MimeTypeTest.cs new file mode 100644 index 000000000..a9e143e1e --- /dev/null +++ b/test/EmbedIO.Tests/MimeTypeTest.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class MimeTypeTest + { + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("text", false)] + [TestCase("/", false)] + [TestCase("text/", false)] + [TestCase("/text", false)] + [TestCase("text/html,", false)] + [TestCase("text,/html", false)] + [TestCase("*/text", false)] + [TestCase("*/*", false)] + [TestCase("text/*", false)] + [TestCase("text/html", true)] + public void IsMimeType_ReturnsCorrectValue(string mimeType, bool isMimeType) + { + Assert.AreEqual(isMimeType, MimeType.IsMimeType(mimeType, false)); + } + + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("text", false)] + [TestCase("/", false)] + [TestCase("text/", false)] + [TestCase("/text", false)] + [TestCase("text/html,", false)] + [TestCase("text,/html", false)] + [TestCase("*/text", false)] + [TestCase("*/*", true)] + [TestCase("text/*", true)] + [TestCase("text/html", true)] + public void IsMimeTypeOrMediaRange_ReturnsCorrectValue(string mimeType, bool isMimeTypeOrMediaRange) + { + Assert.AreEqual(isMimeTypeOrMediaRange, MimeType.IsMimeType(mimeType, true)); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/RegexRoutingTest.cs b/test/EmbedIO.Tests/RegexRoutingTest.cs new file mode 100644 index 000000000..73dddaa78 --- /dev/null +++ b/test/EmbedIO.Tests/RegexRoutingTest.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; +using System.Threading.Tasks; +using EmbedIO.Tests.TestObjects; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class RegexRoutingTest : EndToEndFixtureBase + { + public RegexRoutingTest() + : base(true) + { + } + + protected override void OnSetUp() + { + Server.WithModule(new TestRegexModule("/")); + } + + public class GetData : RegexRoutingTest + { + [Test] + public async Task GetDataWithoutRegex() + { + var call = await Client.GetStringAsync("empty"); + + Assert.AreEqual(string.Empty, call); + } + + [Test] + public async Task GetDataWithRegex() + { + var call = await Client.GetStringAsync("data/1"); + + Assert.AreEqual("1", call); + } + + [Test] + public async Task GetDataWithMultipleRegex() + { + var call = await Client.GetStringAsync("data/1/2"); + + Assert.AreEqual("2", call); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/ResourceFileProviderTest.cs b/test/EmbedIO.Tests/ResourceFileProviderTest.cs new file mode 100644 index 000000000..b49e2076e --- /dev/null +++ b/test/EmbedIO.Tests/ResourceFileProviderTest.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Linq; +using System.Text; +using EmbedIO.Files; +using EmbedIO.Testing; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class ResourceFileProviderTest + { + private readonly IFileProvider _fileProvider = new ResourceFileProvider( + typeof(TestWebServer).Assembly, + typeof(TestWebServer).Namespace + ".Resources"); + + private readonly IMimeTypeProvider _mimeTypeProvider = new MockMimeTypeProvider(); + + [TestCase("/index.html", "index.html")] + [TestCase("/sub/index.html", "index.html")] + public void MapFile_ReturnsCorrectFileInfo(string urlPath, string name) + { + var info = _fileProvider.MapUrlPath(urlPath, _mimeTypeProvider); + + Assert.IsNotNull(info, "info != null"); + Assert.IsTrue(info.IsFile, "info.IsFile == true"); + Assert.AreEqual(name, info.Name, "info.Name has the correct value"); + Assert.AreEqual(StockResource.GetLength(urlPath), info.Length, "info.Length has the correct value"); + } + + [TestCase("/index.html")] + [TestCase("/sub/index.html")] + public void OpenFile_ReturnsCorrectContent(string urlPath) + { + var info = _fileProvider.MapUrlPath(urlPath, _mimeTypeProvider); + + var expectedText = StockResource.GetText(urlPath, Encoding.UTF8); + string actualText; + using (var stream = _fileProvider.OpenFile(info.Path)) + using (var reader = new StreamReader(stream, Encoding.UTF8, false, WebServer.StreamCopyBufferSize, true)) + { + actualText = reader.ReadToEnd(); + } + + Assert.AreEqual(expectedText, actualText, "Content is the same as embedded resource"); + } + + [Test] + public void GetDirectoryEntries_ReturnsEmptyEnumerable() + { + var entries = _fileProvider.GetDirectoryEntries(string.Empty, _mimeTypeProvider); + Assert.IsFalse(entries.Any(), "There are no entries"); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/RoutingTest.cs b/test/EmbedIO.Tests/RoutingTest.cs new file mode 100644 index 000000000..a2b8fc135 --- /dev/null +++ b/test/EmbedIO.Tests/RoutingTest.cs @@ -0,0 +1,155 @@ +using System; +using System.Linq; +using EmbedIO.Routing; +using NUnit.Framework; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class RoutingTest + { + [TestCase("")] // Route is empty. + [TestCase("abc")] // Route does not start with a slash. + [TestCase("/abc/{id")] // Route syntax error: unclosed parameter specification. + [TestCase("/abc/{}")] // Route syntax error: empty parameter specification. + [TestCase("/abc/{?}")] // Route syntax error: missing parameter name. + [TestCase("/abc/{myp@rameter}")] // Route syntax error: parameter name contains one or more invalid characters. + [TestCase("/abc/{id}/def/{id}")] // Route syntax error: duplicate parameter name. + [TestCase("/abc/{id}{name}")] // Route syntax error: parameters must be separated by literal text. + public void InvalidRoute_IsNotValid(string route) + { + RouteMatcher.ClearCache(); + + Assert.IsFalse(Route.IsValid(route, false)); + Assert.Throws(() => RouteMatcher.Parse(route, false)); + Assert.IsFalse(RouteMatcher.TryParse(route, false, out _)); + } + + [TestCase("")] // Route is empty. + [TestCase("abc/")] // Route does not start with a slash. + [TestCase("/abc/{id/")] // Route syntax error: unclosed parameter specification. + [TestCase("/abc/{}/")] // Route syntax error: empty parameter specification. + [TestCase("/abc/{myp@rameter}/")] // Route syntax error: parameter name contains one or more invalid characters. + [TestCase("/abc/{id}/def/{id}/")] // Route syntax error: duplicate parameter name. + [TestCase("/abc/{id}{name}/")] // Route syntax error: parameters must be separated by literal text. + [TestCase("/abc/{id}/{name?}/")] // No segment of a base route can be optional. + public void InvalidBaseRoute_IsNotValid(string route) + { + RouteMatcher.ClearCache(); + + Assert.IsFalse(Route.IsValid(route, true)); + Assert.Throws(() => RouteMatcher.Parse(route, true)); + Assert.IsFalse(RouteMatcher.TryParse(route, true, out _)); + } + + [TestCase("/")] // Root. + [TestCase("/abc/def")] // No parameters. + [TestCase("/abc/{id}")] // 1 parameter, takes a whole segment. + [TestCase("/abc/{id?}")] // 1 optional parameter, takes a whole segment. + [TestCase("/a{id}")] // 1 parameter, at start of segment. + [TestCase("/{id}b")] // 1 parameter, at end of segment. + [TestCase("/a{id}b")] // 1 parameter, mid-segment. + [TestCase("/abc/{width}x{height}")] // 2 parameters, same segment. + [TestCase("/abc/{width}/{height}")] // 2 parameters, different segments. + [TestCase("/abc/{id}/{date?}")] // 2 parameters, different segments, 1 optional. + public void ValidRoute_IsValid(string route) + { + RouteMatcher.ClearCache(); + + Assert.IsTrue(Route.IsValid(route, false)); + Assert.DoesNotThrow(() => RouteMatcher.Parse(route, false)); + Assert.IsTrue(RouteMatcher.TryParse(route, false, out _)); + } + + [TestCase("/")] // Root. + [TestCase("/abc/def/")] // No parameters. + [TestCase("/abc/{id}/")] // 1 parameter, takes a whole segment. + [TestCase("/a{id}/")] // 1 parameter, at start of segment. + [TestCase("/{id}b/")] // 1 parameter, at end of segment. + [TestCase("/a{id}b/")] // 1 parameter, mid-segment. + [TestCase("/abc/{width}x{height}/")] // 2 parameters, same segment. + [TestCase("/abc/{width}/{height}/")] // 2 parameters, different segments. + public void ValidBaseRoute_IsValid(string route) + { + RouteMatcher.ClearCache(); + + Assert.IsTrue(Route.IsValid(route, true)); + Assert.DoesNotThrow(() => RouteMatcher.Parse(route, true)); + Assert.IsTrue(RouteMatcher.TryParse(route, true, out _)); + } + + [TestCase("/")] // Root. + [TestCase("/abc/def")] // No parameters. + [TestCase("/abc/{id}", "id")] // 1 parameter, takes a whole segment. + [TestCase("/abc/{id?}", "id")] // 1 optional parameter, takes a whole segment. + [TestCase("/a{id}", "id")] // 1 parameter, at start of segment. + [TestCase("/{id}b", "id")] // 1 parameter, at end of segment. + [TestCase("/a{id}b", "id")] // 1 parameter, mid-segment. + [TestCase("/abc/{width}x{height}", "width", "height")] // 2 parameters, same segment. + [TestCase("/abc/{width}/{height}", "width", "height")] // 2 parameters, different segments. + [TestCase("/abc/{id}/{date?}", "id", "date")] // 2 parameters, different segments, 1 optional. + public void RouteParameters_HaveCorrectNames(string route, params string[] parameterNames) + { + RouteMatcher.ClearCache(); + + Assert.IsTrue(RouteMatcher.TryParse(route, false, out var matcher)); + Assert.AreEqual(parameterNames.Length, matcher.ParameterNames.Count); + for (var i = 0; i < parameterNames.Length; i++) + Assert.AreEqual(parameterNames[i], matcher.ParameterNames[i]); + } + + [TestCase("/", "/")] // Root. + [TestCase("/abc/def", "/abc/def")] + [TestCase("/abc/{id}", "/abc/123", "id", "123")] + [TestCase("/abc/{id?}", "/abc", "id", "")] + [TestCase("/abc/{id}/{date}", "/abc/123/20190223", "id", "123", "date", "20190223")] + [TestCase("/abc/{id}/{date?}", "/abc/123", "id", "123", "date", "")] + [TestCase("/abc/{id?}/{date}", "/abc/20190223", "id", "", "date", "20190223")] + public void MatchedRoute_HasCorrectParameters(string route, string path, params string[] parameters) + { + if (parameters.Length % 2 != 0) + throw new InvalidOperationException("Parameters should be in name, value pairs."); + + RouteMatcher.ClearCache(); + + var parameterCount = parameters.Length / 2; + Assert.IsTrue(RouteMatcher.TryParse(route, false, out var matcher)); + Assert.AreEqual(parameterCount, matcher.ParameterNames.Count); + for (var i = 0; i < parameterCount; i++) + Assert.AreEqual(parameters[2 * i], matcher.ParameterNames[i]); + + var match = matcher.Match(path); + Assert.IsNotNull(match); + var keys = match.Keys.ToArray(); + var values = match.Values.ToArray(); + Assert.AreEqual(parameterCount, keys.Length); + Assert.AreEqual(parameterCount, values.Length); + for (var i = 0; i < parameterCount; i++) + { + Assert.AreEqual(parameters[2 * i], keys[i]); + Assert.AreEqual(parameters[(2 * i) + 1], values[i]); + } + } + + [TestCase("/", "/", "/")] + [TestCase("/", "/SUBPATH", "/SUBPATH")] + [TestCase("/abc/def/", "/abc/def", "/")] + [TestCase("/abc/def/", "/abc/def/SUBPATH", "/SUBPATH")] + [TestCase("/abc/{id}/", "/abc/123", "/")] + [TestCase("/abc/{id}/", "/abc/123/SUBPATH", "/SUBPATH")] + [TestCase("/abc/{width}x{height}/", "/abc/123x456", "/")] + [TestCase("/abc/{width}x{height}/", "/abc/123x456/SUBPATH", "/SUBPATH")] + [TestCase("/abc/{id}/{date}/", "/abc/123/20190223", "/")] + [TestCase("/abc/{id}/{date}/", "/abc/123/20190223/SUBPATH", "/SUBPATH")] + public void MatchedBaseRoute_HasCorrectSubPath(string route, string path, string subPath) + { + RouteMatcher.ClearCache(); + + Assert.IsTrue(RouteMatcher.TryParse(route, true, out var matcher)); + + var match = matcher.Match(path); + Assert.IsNotNull(match); + Assert.AreEqual(subPath, match.SubPath); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/SetUpFixture.cs b/test/EmbedIO.Tests/SetUpFixture.cs new file mode 100644 index 000000000..b6aba721a --- /dev/null +++ b/test/EmbedIO.Tests/SetUpFixture.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; +using Swan; + +namespace EmbedIO.Tests +{ + [SetUpFixture] + public class SetUpFixture + { + [OneTimeSetUp] + public void OnBeforeAnyTests() + { + // Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/StaticFilesModuleTest.cs b/test/EmbedIO.Tests/StaticFilesModuleTest.cs new file mode 100644 index 000000000..bdbdb33b8 --- /dev/null +++ b/test/EmbedIO.Tests/StaticFilesModuleTest.cs @@ -0,0 +1,291 @@ +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using EmbedIO.Testing; +using EmbedIO.Utilities; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class StaticFilesModuleTest : EndToEndFixtureBase + { + protected StaticFilesModuleTest() + : base(false) + { + ServedFolder = new StaticFolder.WithDataFiles(nameof(StaticFilesModuleTest)); + } + + protected StaticFolder.WithDataFiles ServedFolder { get; } + + protected override void Dispose(bool disposing) + { + ServedFolder.Dispose(); + } + + protected override void OnSetUp() + { + Server + .WithStaticFolder("/", StaticFolder.RootPathOf(nameof(StaticFilesModuleTest)), true); + } + + public class GetFiles : StaticFilesModuleTest + { + [Test] + public async Task Index() + { + var request = new HttpRequestMessage(HttpMethod.Get, UrlPath.Root); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var html = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(Resources.Index, html, "Same content index.html"); + + Assert.IsTrue(string.IsNullOrWhiteSpace(response.Headers.Pragma.ToString()), "Pragma empty"); + } + + request = new HttpRequestMessage(HttpMethod.Get, UrlPath.Root); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + } + } + + [TestCase("sub/")] + [TestCase("sub")] + public async Task SubFolderIndex(string url) + { + var html = await Client.GetStringAsync(url); + Assert.AreEqual(Resources.SubIndex, html, $"Same content {url}"); + } + + [Test] + public async Task TestHeadIndex() + { + var request = new HttpRequestMessage(HttpMethod.Head, UrlPath.Root); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var html = await response.Content.ReadAsStringAsync(); + + Assert.IsEmpty(html, "Content Empty"); + } + } + + [Test] + public async Task FileWritable() + { + var root = Path.GetTempPath(); + var file = Path.Combine(root, "index.html"); + File.WriteAllText(file, Resources.Index); + + using (var server = new TestWebServer()) + { + server + .WithStaticFolder("/", root, false) + .Start(); + + var remoteFile = await server.Client.GetStringAsync(UrlPath.Root); + File.WriteAllText(file, Resources.SubIndex); + + var remoteUpdatedFile = await server.Client.GetStringAsync(UrlPath.Root); + File.WriteAllText(file, nameof(WebServer)); + + Assert.AreEqual(Resources.Index, remoteFile); + Assert.AreEqual(Resources.SubIndex, remoteUpdatedFile); + } + } + + [Test] + public async Task SensitiveFile() + { + var file = Path.GetTempPath() + Guid.NewGuid().ToString().ToLower(); + File.WriteAllText(file, string.Empty); + + Assert.IsTrue(File.Exists(file), "File was created"); + + if (File.Exists(file.ToUpper())) + { + Assert.Ignore("File-system is not case sensitive."); + } + + var htmlUpperCase = await Client.GetStringAsync(StaticFolder.WithDataFiles.UppercaseFile); + Assert.AreEqual(nameof(StaticFolder.WithDataFiles.UppercaseFile), htmlUpperCase, "Same content upper case"); + + var htmlLowerCase = await Client.GetStringAsync(StaticFolder.WithDataFiles.LowercaseFile); + Assert.AreEqual(nameof(StaticFolder.WithDataFiles.LowercaseFile), htmlLowerCase, "Same content lower case"); + } + } + + public class GetPartials : StaticFilesModuleTest + { + [TestCase("Got initial part of file", 0, 1024)] + [TestCase("Got middle part of file", StaticFolder.WithDataFiles.BigDataSize / 2, 1024)] + [TestCase("Got final part of file", StaticFolder.WithDataFiles.BigDataSize - 1024, 1024)] + public async Task GetPartialContent(string message, int offset, int length) + { + var request = new HttpRequestMessage(HttpMethod.Get, StaticFolder.WithDataFiles.BigDataFile); + request.Headers.Range = new RangeHeaderValue(offset, offset + length - 1); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.PartialContent, response.StatusCode, "Responds with 216 Partial Content"); + + using (var ms = new MemoryStream()) + { + var responseStream = await response.Content.ReadAsStreamAsync(); + responseStream.CopyTo(ms); + var data = ms.ToArray(); + Assert.IsTrue(ServedFolder.BigData.Skip(offset).Take(length).SequenceEqual(data), message); + } + } + } + + [Test] + public async Task NotPartial() + { + var request = new HttpRequestMessage(HttpMethod.Get, StaticFolder.WithDataFiles.BigDataFile); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + var data = await response.Content.ReadAsByteArrayAsync(); + + Assert.IsNotNull(data, "Data is not empty"); + Assert.IsTrue(ServedFolder.BigData.SequenceEqual(data)); + } + } + + [Test] + public async Task ReconstructFileFromPartials() + { + var requestHead = new HttpRequestMessage(HttpMethod.Get, StaticFolder.WithDataFiles.BigDataFile); + + int remoteSize; + using (var res = await Client.SendAsync(requestHead)) + { + remoteSize = (await res.Content.ReadAsByteArrayAsync()).Length; + } + + Assert.AreEqual(StaticFolder.WithDataFiles.BigDataSize, remoteSize); + + var buffer = new byte[remoteSize]; + const int chunkSize = 100000; + for (var offset = 0; offset < remoteSize; offset += chunkSize) + { + var request = new HttpRequestMessage(HttpMethod.Get, StaticFolder.WithDataFiles.BigDataFile); + var top = Math.Min(offset + chunkSize, remoteSize) - 1; + + request.Headers.Range = new RangeHeaderValue(offset, top); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.PartialContent, response.StatusCode); + + using (var ms = new MemoryStream()) + { + var stream = await response.Content.ReadAsStreamAsync(); + stream.CopyTo(ms); + Buffer.BlockCopy(ms.GetBuffer(), 0, buffer, offset, (int)ms.Length); + } + } + } + + Assert.IsTrue(ServedFolder.BigData.SequenceEqual(buffer)); + } + + [Test] + public async Task InvalidRange_RespondsWith416() + { + var requestHead = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + StaticFolder.WithDataFiles.BigDataFile); + + using (var res = await Client.SendAsync(requestHead)) + { + var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + StaticFolder.WithDataFiles.BigDataFile); + request.Headers.Range = new RangeHeaderValue(0, StaticFolder.WithDataFiles.BigDataSize + 10); + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.AreEqual(StaticFolder.WithDataFiles.BigDataSize, response.Content.Headers.ContentRange.Length); + } + } + } + } + + public class CompressFile : StaticFilesModuleTest + { + [Test] + public async Task GetGzip() + { + var request = new HttpRequestMessage(HttpMethod.Get, UrlPath.Root); + request.Headers.AcceptEncoding.Clear(); + byte[] compressedBytes; + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + using (var memoryStream = new MemoryStream()) + { + using (var compressor = new GZipStream(memoryStream, CompressionMode.Compress)) + using (var responseStream = await response.Content.ReadAsStreamAsync()) + responseStream.CopyTo(compressor); + + compressedBytes = memoryStream.ToArray(); + } + } + + request = new HttpRequestMessage(HttpMethod.Get, UrlPath.Root); + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue(CompressionMethodNames.Gzip)); + byte[] compressedResponseBytes; + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + compressedResponseBytes = await response.Content.ReadAsByteArrayAsync(); + } + + Assert.IsTrue(compressedResponseBytes.SequenceEqual(compressedBytes)); + } + } + + public class Etag : StaticFilesModuleTest + { + [Test] + public async Task GetEtag() + { + var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + string entityTag; + + using (var response = await Client.SendAsync(request)) + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK"); + + // Can't use response.Headers.Etag, it's always null + Assert.NotNull(response.Headers.FirstOrDefault(x => x.Key == "ETag"), "ETag is not null"); + entityTag = response.Headers.First(x => x.Key == "ETag").Value.First(); + } + + var secondRequest = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); + secondRequest.Headers.TryAddWithoutValidation(HttpHeaderNames.IfNoneMatch, entityTag); + + using (var response = await Client.SendAsync(secondRequest)) + { + Assert.AreEqual(HttpStatusCode.NotModified, response.StatusCode, "Status Code NotModified"); + } + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/JsonDataAttribute.cs b/test/EmbedIO.Tests/TestObjects/JsonDataAttribute.cs new file mode 100644 index 000000000..b95003738 --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/JsonDataAttribute.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using EmbedIO.WebApi; + +namespace EmbedIO.Tests.TestObjects +{ + [AttributeUsage(AttributeTargets.Parameter)] + public class JsonDataAttribute : Attribute, IRequestDataAttribute + { + public async Task GetRequestDataAsync(WebApiController controller, Type type, string parameterName) + { + string body; + using (var reader = controller.HttpContext.OpenRequestText()) + { + body = await reader.ReadToEndAsync().ConfigureAwait(false); + } + + try + { + return Swan.Formatters.Json.Deserialize(body, type); + } + catch (FormatException) + { + throw HttpException.BadRequest($"Expected request body to be deserializable to {type.FullName}."); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/ObjectExtensions.cs b/test/EmbedIO.Tests/TestObjects/ObjectExtensions.cs new file mode 100644 index 000000000..d247ed4dd --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/ObjectExtensions.cs @@ -0,0 +1,7 @@ +namespace EmbedIO.Tests.TestObjects +{ + internal static class ObjectExtensions + { + public static void Void(this T @this) { } + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PeopleRepository.cs b/test/EmbedIO.Tests/TestObjects/PeopleRepository.cs similarity index 76% rename from test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PeopleRepository.cs rename to test/EmbedIO.Tests/TestObjects/PeopleRepository.cs index dc5516e4e..784c51a07 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PeopleRepository.cs +++ b/test/EmbedIO.Tests/TestObjects/PeopleRepository.cs @@ -1,25 +1,12 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System; - using System.Collections.Generic; - - public class Person - { - public int Key { get; set; } - public string Name { get; set; } - public int Age { get; set; } - public DateTime DoB { get; set; } - public string EmailAddress { get; set; } - public string PhotoUrl { get; set; } - public string MainSkill { get; set; } - } +using System; +using System.Collections.Generic; +namespace EmbedIO.Tests.TestObjects +{ public static class PeopleRepository { - public static List Database => new List - { - new Person() - { + public static List Database => new List { + new Person { Key = 1, Name = "Mario Di Vece", Age = 31, @@ -27,8 +14,7 @@ public static class PeopleRepository DoB = new DateTime(1980, 1, 1), MainSkill = "CSharp", }, - new Person() - { + new Person { Key = 2, Name = "Geovanni Perez", Age = 32, @@ -36,8 +22,7 @@ public static class PeopleRepository DoB = new DateTime(1980, 2, 2), MainSkill = "Javascript", }, - new Person() - { + new Person { Key = 3, Name = "Luis Gonzalez", Age = 29, @@ -47,4 +32,14 @@ public static class PeopleRepository }, }; } + + public class Person + { + public int Key { get; set; } + public string Name { get; set; } + public int Age { get; set; } + public DateTime DoB { get; set; } + public string EmailAddress { get; set; } + public string MainSkill { get; set; } + } } \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/PersonEndToEndFixtureBase.cs b/test/EmbedIO.Tests/TestObjects/PersonEndToEndFixtureBase.cs new file mode 100644 index 000000000..d2490d1b1 --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/PersonEndToEndFixtureBase.cs @@ -0,0 +1,30 @@ +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Swan.Formatters; + +namespace EmbedIO.Tests.TestObjects +{ + public abstract class PersonEndToEndFixtureBase : EndToEndFixtureBase + { + protected PersonEndToEndFixtureBase(bool useTestWebServer) + : base(useTestWebServer) + { + } + + protected async Task ValidatePersonAsync(string url) + { + var current = PeopleRepository.Database.First(); + + var jsonBody = await Client.GetStringAsync(url); + + Assert.IsNotNull(jsonBody, "Json Body is not null"); + Assert.IsNotEmpty(jsonBody, "Json Body is not empty"); + + var item = Json.Deserialize(jsonBody); + + Assert.IsNotNull(item, "Json Object is not null"); + Assert.AreEqual(item.Name, current.Name, "Remote objects equality"); + } + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/Resources.cs b/test/EmbedIO.Tests/TestObjects/Resources.cs similarity index 65% rename from test/Unosquare.Labs.EmbedIO.Tests/TestObjects/Resources.cs rename to test/EmbedIO.Tests/TestObjects/Resources.cs index 5d594a8dc..0fd09ac8a 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/Resources.cs +++ b/test/EmbedIO.Tests/TestObjects/Resources.cs @@ -1,11 +1,9 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System.Threading; +using System.Threading; +namespace EmbedIO.Tests.TestObjects +{ public static class Resources { - public static int Counter = 9699; - public static readonly string SubIndex = @" @@ -29,13 +27,15 @@ public static class Resources This is a placeholder "; - - private const string ServerAddress = "http://localhost:{0}/"; + + private static int _counter = 9699; public static string GetServerAddress() { - Interlocked.Increment(ref Counter); - return string.Format(ServerAddress, Counter); + const string serverAddress = "http://localhost:{0}/"; + + Interlocked.Increment(ref _counter); + return string.Format(serverAddress, _counter); } } } diff --git a/test/EmbedIO.Tests/TestObjects/StaticFolder.cs b/test/EmbedIO.Tests/TestObjects/StaticFolder.cs new file mode 100644 index 000000000..eb5fe41d9 --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/StaticFolder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; +using EmbedIO.Files; + +namespace EmbedIO.Tests.TestObjects +{ + public abstract class StaticFolder : IDisposable + { + protected StaticFolder(string folderName) + { + RootPath = RootPathOf(folderName); + Directory.CreateDirectory(RootPath); + Directory.CreateDirectory(PathOf("sub")); + + File.WriteAllText(PathOf(FileModule.DefaultDocumentName), Resources.Index); + File.WriteAllText(PathOf("sub", FileModule.DefaultDocumentName), Resources.SubIndex); + } + + ~StaticFolder() + { + Dispose(false); + } + + public string RootPath { get; } + + public static string RootPathOf(string folderName) + { + var assemblyPath = Path.GetDirectoryName(typeof(StaticFilesModuleTest).Assembly.Location); + return Path.Combine(assemblyPath ?? throw new InvalidOperationException(), folderName); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + Directory.Delete(RootPath, true); + } + + protected string PathOf(string path) => Path.Combine(RootPath, path); + + protected string PathOf(string path1, string path2) => Path.Combine(RootPath, path1, path2); + + public sealed class WithIndexOnly : StaticFolder + { + public WithIndexOnly(string folderName) + : base(folderName) + { + } + } + + public sealed class WithDataFiles : StaticFolder + { + public const string BigDataFile = "bigdata.bin"; + public const int BigDataSize = BigDataSizeMb * 1024 * 1024; + + public const string SmallDataFile = "smalldata.bin"; + public const int SmallDataSize = SmallDataSizeMb * 1024 * 1024; + + public const string LowercaseFile = "abcdef.txt"; + + public const string UppercaseFile = "ABCDEF.txt"; + + private const int BigDataSizeMb = 10; + private const int SmallDataSizeMb = 1; + + public WithDataFiles(string folderName) + : base(folderName) + { + var bigData = CreateRandomData(BigDataSize); + File.WriteAllBytes(PathOf(BigDataFile), bigData); + BigData = bigData; + + var smallData = CreateRandomData(SmallDataSize); + File.WriteAllBytes(PathOf(SmallDataFile), smallData); + SmallData = smallData; + + File.WriteAllText(PathOf(LowercaseFile), nameof(LowercaseFile)); + File.WriteAllText(PathOf(UppercaseFile), nameof(UppercaseFile)); + } + + public IReadOnlyList BigData { get; } + + public IReadOnlyList SmallData { get; } + + private static byte[] CreateRandomData(int size) + { + var rng = new Random(); + var data = new byte[size]; + rng.NextBytes(data); + return data; + } + } + + public sealed class WithHtmlFiles : StaticFolder + { + public static readonly IReadOnlyList RandomHtmls = new[] { "abc.html", "wkp.html", "zxy.html" }; + + public WithHtmlFiles(string folderName) + : base(folderName) + { + foreach (var file in RandomHtmls) + { + File.WriteAllText(PathOf(file), Resources.Index); + File.WriteAllText(PathOf("sub", file), Resources.SubIndex); + } + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/TestController.cs b/test/EmbedIO.Tests/TestObjects/TestController.cs new file mode 100644 index 000000000..541da7b9f --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/TestController.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using EmbedIO.Routing; +using EmbedIO.Utilities; +using EmbedIO.WebApi; + +namespace EmbedIO.Tests.TestObjects +{ + public class TestController : WebApiController + { + public const string EchoPath = "echo"; + public const string QueryTestPath = "testQuery"; + public const string QueryFieldTestPath = "testQueryField"; + + [Route(HttpVerbs.Get, "/empty")] + public void GetEmpty() + { + } + + [Route(HttpVerbs.Get, "/regex")] + public List GetPeople() => PeopleRepository.Database; + + [Route(HttpVerbs.Post, "/regex")] + public Person PostPeople([JsonData] Person person) => person; + + [Route(HttpVerbs.Get, "/regex/{id}")] + public Person GetPerson(int id) => CheckPerson(id); + + [Route(HttpVerbs.Get, "/regexopt/{id?}")] + public object GetPerson(int? id) + => id.HasValue ? (object)CheckPerson(id.Value) : PeopleRepository.Database; + + [Route(HttpVerbs.Get, "/regexdate/{date}")] + public Person GetPerson(DateTime date) + => PeopleRepository.Database.FirstOrDefault(p => p.DoB == date) + ?? throw HttpException.NotFound(); + + [Route(HttpVerbs.Get, "/regextwo/{skill}/{age}")] + public Person GetPerson(string skill, int age) + => PeopleRepository.Database.FirstOrDefault(p => string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase) && p.Age == age) + ?? throw HttpException.NotFound(); + + [Route(HttpVerbs.Get, "/regexthree/{skill}/{age?}")] + public Person GetOptionalPerson(string skill, int? age = null) + { + var item = age == null + ? PeopleRepository.Database.FirstOrDefault(p => string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase)) + : PeopleRepository.Database.FirstOrDefault(p => string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase) && p.Age == age); + + return item ?? throw HttpException.NotFound(); + } + + [Route(HttpVerbs.Post, "/" + EchoPath)] + public Dictionary PostEcho([FormData] NameValueCollection data) + => data.ToDictionary(); + + [Route(HttpVerbs.Get, "/" + QueryTestPath)] + public Dictionary TestQuery([QueryData] NameValueCollection data) + => data.ToDictionary(); + + [Route(HttpVerbs.Get, "/" + QueryFieldTestPath)] + public string TestQueryField([QueryField] string id) => id; + + private static Person CheckPerson(int id) + =>PeopleRepository.Database.FirstOrDefault(p => p.Key == id) + ?? throw HttpException.NotFound(); + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/TestLocalSessionController.cs b/test/EmbedIO.Tests/TestObjects/TestLocalSessionController.cs new file mode 100644 index 000000000..db8cc1fdb --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/TestLocalSessionController.cs @@ -0,0 +1,45 @@ +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.WebApi; + +namespace EmbedIO.Tests.TestObjects +{ + public class TestLocalSessionController : WebApiController + { + public const string DeleteSession = "api/deletesession"; + public const string PutData = "api/putdata"; + public const string GetData = "api/getdata"; + public const string GetCookie = "api/getcookie"; + + public const string MyData = "MyData"; + public const string CookieName = "MyCookie"; + + [Route(HttpVerbs.Get, "/getcookie")] + public Task GetCookieC() + { + var cookie = new System.Net.Cookie(CookieName, CookieName); + Response.Cookies.Add(cookie); + + return HttpContext.SendStringAsync(Response.Cookies[CookieName].Value, MimeType.PlainText, Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/deletesession")] + public Task DeleteSessionC() + { + HttpContext.Session.Delete(); + return HttpContext.SendStringAsync("Deleted", MimeType.PlainText, Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/putdata")] + public Task PutDataSession() + { + HttpContext.Session["sessionData"] = MyData; + return HttpContext.SendStringAsync(HttpContext.Session["sessionData"].ToString(), MimeType.PlainText, Encoding.UTF8); + } + + [Route(HttpVerbs.Get, "/getdata")] + public Task GetDataSession() + => HttpContext.SendStringAsync(HttpContext.Session["sessionData"]?.ToString() ?? string.Empty, MimeType.PlainText, Encoding.UTF8); + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/TestRegexModule.Controller.cs b/test/EmbedIO.Tests/TestObjects/TestRegexModule.Controller.cs new file mode 100644 index 000000000..671df224f --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/TestRegexModule.Controller.cs @@ -0,0 +1,26 @@ +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Routing; +using EmbedIO.WebApi; + +namespace EmbedIO.Tests.TestObjects +{ + partial class TestRegexModule + { + public class Controller : WebApiController + { + [Route(HttpVerbs.Any, "/data/{id}")] + public Task Id(string id) + => HttpContext.SendStringAsync(id, MimeType.PlainText, Encoding.UTF8); + + [Route(HttpVerbs.Any, "/data/{id}/{time?}")] + public Task Time(string id, string time) + => HttpContext.SendStringAsync(time, MimeType.PlainText, Encoding.UTF8); + + [Route(HttpVerbs.Any, "/empty")] + public void Empty() + { + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/TestObjects/TestRegexModule.cs b/test/EmbedIO.Tests/TestObjects/TestRegexModule.cs new file mode 100644 index 000000000..1bbb04d9c --- /dev/null +++ b/test/EmbedIO.Tests/TestObjects/TestRegexModule.cs @@ -0,0 +1,14 @@ +using EmbedIO.WebApi; + +namespace EmbedIO.Tests.TestObjects +{ + public sealed partial class TestRegexModule : WebApiModuleBase + { + public TestRegexModule(string baseRoute) + : base(baseRoute) + { + RegisterControllerType(); + LockConfiguration(); + } + } +} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebSocket.cs b/test/EmbedIO.Tests/TestObjects/TestWebSocket.cs similarity index 98% rename from test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebSocket.cs rename to test/EmbedIO.Tests/TestObjects/TestWebSocket.cs index e8cccca0e..c935869be 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebSocket.cs +++ b/test/EmbedIO.Tests/TestObjects/TestWebSocket.cs @@ -1,59 +1,23 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using Modules; - using Swan.Formatters; +using System.Threading.Tasks; +using EmbedIO.WebSockets; +using Swan.Formatters; - [WebSocketHandler("/test/")] - public class TestWebSocketBase : WebSocketsServer +namespace EmbedIO.Tests.TestObjects +{ + public class TestWebSocket : WebSocketModule { - public override string ServerName => nameof(TestWebSocketBase); - - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) - { - Send(context, "HELLO"); - } - - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + public TestWebSocket(string urlPath) + : base(urlPath, true) { - // Do nothing } - protected override void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - // Do nothing - } - - protected override void OnClientDisconnected(IWebSocketContext context) - { - // Do nothing - } + protected override Task OnMessageReceivedAsync(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + => SendAsync(context, "HELLO"); } - [WebSocketHandler("/test/")] - public class TestWebSocket : TestWebSocketBase + public class BigDataWebSocket : WebSocketModule { - public override string ServerName => nameof(TestWebSocket); - } - - [WebSocketHandler("/test/*")] - public class TestWebSocketWildcard : TestWebSocketBase - { - public override string ServerName => nameof(TestWebSocketWildcard); - } - - [WebSocketHandler("/test/{id}")] - public class TestWebSocketRegex : TestWebSocketBase - { - public override string ServerName => nameof(TestWebSocketRegex); - } - - [WebSocketHandler("/bigdata")] - public class BigDataWebSocket : WebSocketsServer - { - public static object BigDataObject => new + public static readonly object BigDataObject = new { Id = 1, Name = "Name", @@ -2898,58 +2862,29 @@ public class BigDataWebSocket : WebSocketsServer cygSR/MggDhTGBrfglUEKIXXbcbfwgukfyVEJJPOIP0xTtdAhAKBTNyWZuTIcRmIjIcgEEau", }; - public override string ServerName => nameof(BigDataWebSocket); - - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) - { - Send(context, Json.Serialize(BigDataObject)); - } - - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + public BigDataWebSocket(string urlPath) + : base(urlPath, true) { - // Do Nothing } - protected override void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - // Do nothing - } - - protected override void OnClientDisconnected(IWebSocketContext context) - { - // Do nothing - } + protected override Task OnMessageReceivedAsync(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + => SendAsync(context, Json.Serialize(BigDataObject)); } - [WebSocketHandler("/close")] - public class CloseWebSocket : WebSocketsServer + public class CloseWebSocket : WebSocketModule { - public override string ServerName => nameof(BigDataWebSocket); - - protected override void OnMessageReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + public CloseWebSocket(string urlPath) + : base(urlPath, true) { - // Do nothing } - protected override void OnFrameReceived(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) - { - // Do nothing - } + protected override Task OnMessageReceivedAsync( + IWebSocketContext context, + byte[] rxBuffer, + IWebSocketReceiveResult rxResult) + => Task.CompletedTask; - protected override void OnClientConnected( - IWebSocketContext context, - System.Net.IPEndPoint localEndPoint, - System.Net.IPEndPoint remoteEndPoint) - { - context.WebSocket.CloseAsync(Net.CloseStatusCode.InvalidData, "Your data is invalid"); - } - - protected override void OnClientDisconnected(IWebSocketContext context) - { - // Do nothing - } + protected override Task OnClientConnectedAsync(IWebSocketContext context) + => context.WebSocket.CloseAsync(CloseStatusCode.InvalidData, "Your data is invalid"); } } \ No newline at end of file diff --git a/test/EmbedIO.Tests/Utilities/UniqueIdGeneratorTest.cs b/test/EmbedIO.Tests/Utilities/UniqueIdGeneratorTest.cs new file mode 100644 index 000000000..4096f5884 --- /dev/null +++ b/test/EmbedIO.Tests/Utilities/UniqueIdGeneratorTest.cs @@ -0,0 +1,25 @@ +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests.Utilities +{ + public class UniqueIdGeneratorTest + { + [Test] + public void GetNext_ReturnsValidString() + { + var id = UniqueIdGenerator.GetNext(); + Assert.IsNotNull(id); + Assert.IsNotEmpty(id); + } + + [Test] + public void GetNext_ReturnsUniqueId() + { + var ids = new string[100]; + for (var i = 0; i < ids.Length; i++) + ids[i] = UniqueIdGenerator.GetNext(); + CollectionAssert.AllItemsAreUnique(ids); + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/Utilities/UrlPathTests.cs b/test/EmbedIO.Tests/Utilities/UrlPathTests.cs new file mode 100644 index 000000000..64e7f2b4f --- /dev/null +++ b/test/EmbedIO.Tests/Utilities/UrlPathTests.cs @@ -0,0 +1,111 @@ +using System; +using EmbedIO.Utilities; +using NUnit.Framework; + +namespace EmbedIO.Tests.Utilities +{ + public class UrlPathTests + { + [TestCase(null, false)] + [TestCase("", false)] + [TestCase("does/not/start/with/slash", false)] + [TestCase("/", true)] + [TestCase("/starts/with/slash", true)] + public void IsValid_ReturnsCorrectValue(string urlPath, bool expectedResult) + => Assert.AreEqual(expectedResult, UrlPath.IsValid(urlPath)); + + [TestCase(true)] + [TestCase(false)] + public void Normalize_OnNullUrlPath_ThrowsArgumentNullException(bool isBasePath) + => Assert.Throws(() => UrlPath.Normalize(null, isBasePath)); + + [TestCase(true)] + [TestCase(false)] + public void Normalize_OnEmptyUrlPath_ThrowsArgumentException(bool isBasePath) + => Assert.Throws(() => UrlPath.Normalize("", isBasePath)); + + [TestCase(true)] + [TestCase(false)] + public void Normalize_OnInvalidUrlPath_ThrowsArgumentException(bool isBasePath) + => Assert.Throws(() => UrlPath.Normalize("does/not/start/with/slash", isBasePath)); + + [TestCase("/", false, "/")] + [TestCase("/", true, "/")] + [TestCase("/starts/with/slash", false, "/starts/with/slash")] + [TestCase("/starts/with/slash", true, "/starts/with/slash/")] + [TestCase("//has/multiple///slashes////", false, "/has/multiple/slashes")] + [TestCase("//has/multiple//slashes////", true, "/has/multiple/slashes/")] + public void Normalize_ReturnsCorrectValue(string urlPath, bool isBasePath, string expectedResult) + => Assert.AreEqual(expectedResult, UrlPath.Normalize(urlPath, isBasePath)); + + [TestCase(null, null)] + [TestCase(null, "/api/")] + [TestCase("/api/endpoint", null)] + public void HasPrefix_OnNullParameter_ThrowsArgumentNullException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.HasPrefix(urlPath, baseUrlPath)); + + [TestCase("", "")] + [TestCase("", "/api/")] + [TestCase("/api/endpoint", "")] + public void HasPrefix_OnEmptyParameter_ThrowsArgumentException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.HasPrefix(urlPath, baseUrlPath)); + + [TestCase("!!!", "!!!")] + [TestCase("!!!", "/api/")] + [TestCase("/api/endpoint", "!!!")] + public void HasPrefix_OnInvalidParameter_ThrowsArgumentException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.HasPrefix(urlPath, baseUrlPath)); + + [TestCase("/api/v1/endpoint", "/api/v1", true)] + [TestCase("/api/v1/endpoint", "/api/v1/", true)] + [TestCase("/api/v1/endpoint", "/api/v2", false)] + [TestCase("/api/v1/endpoint", "/api/v2/", false)] + public void HasPrefix_ReturnsCorrectValue(string urlPath, string baseUrlPath, bool expectedResult) + => Assert.AreEqual(expectedResult, UrlPath.HasPrefix(urlPath, baseUrlPath)); + + [TestCase(null, null)] + [TestCase(null, "/api/")] + [TestCase("/api/endpoint", null)] + public void StripPrefix_OnNullParameter_ThrowsArgumentNullException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.StripPrefix(urlPath, baseUrlPath)); + + [TestCase("", "")] + [TestCase("", "/api/")] + [TestCase("/api/endpoint", "")] + public void StripPrefix_OnEmptyParameter_ThrowsArgumentException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.StripPrefix(urlPath, baseUrlPath)); + + [TestCase("!!!", "!!!")] + [TestCase("!!!", "/api/")] + [TestCase("/api/endpoint", "!!!")] + public void StripPrefix_OnInvalidParameter_ThrowsArgumentException(string urlPath, string baseUrlPath) + => Assert.Throws(() => UrlPath.StripPrefix(urlPath, baseUrlPath)); + + [TestCase("/api/v1/endpoint", "/api/v1", "endpoint")] + [TestCase("/api/v1/endpoint", "/api/v1/", "endpoint")] + [TestCase("/api/v1", "/api/v1", "")] + [TestCase("/api/v1", "/api/v1/", "")] + [TestCase("/api/v1/endpoint", "/api/v2", null)] + [TestCase("/api/v1/endpoint", "/api/v2/", null)] + public void StripPrefix_ReturnsCorrectValue(string urlPath, string baseUrlPath, string expectedResult) + => Assert.AreEqual(expectedResult, UrlPath.StripPrefix(urlPath, baseUrlPath)); + + [Test] + public void Split_OnNullUrlPath_ThrowsArgumentNullException() + => Assert.Throws(() => UrlPath.Split(null)); + + [Test] + public void Split_OnEmptyUrlPath_ThrowsArgumentException() + => Assert.Throws(() => UrlPath.Split("")); + + [Test] + public void Split_OnInvalidUrlPath_ThrowsArgumentException() + => Assert.Throws(() => UrlPath.Split("does/not/start/with/slash")); + + [TestCase("/")] + [TestCase("/api/v1/endpoint", "api", "v1", "endpoint")] + [TestCase("///multiple///slashes//get///normalized/", "multiple", "slashes", "get", "normalized")] + public void Split_ReturnsCorrectValues(string urlPath, params string[] segments) + => CollectionAssert.AreEqual(segments, UrlPath.Split(urlPath)); + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/WebApiModuleTest.cs b/test/EmbedIO.Tests/WebApiModuleTest.cs new file mode 100644 index 000000000..5207d2bca --- /dev/null +++ b/test/EmbedIO.Tests/WebApiModuleTest.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using EmbedIO.Tests.TestObjects; +using EmbedIO.WebApi; +using NUnit.Framework; +using Swan.Formatters; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class WebApiModuleTest : PersonEndToEndFixtureBase + { + public WebApiModuleTest() + : base(true) + { + } + + protected override void OnSetUp() + { + Server.WithWebApi("/api", m => m.WithController()); + } + + public class HttpGet : WebApiModuleTest + { + [Test] + public async Task EmptyResponse_ReturnsOk() + { + var response = await Client.GetAsync("/api/empty"); + + Assert.IsNotNull(response); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + } + + public class HttpPost : WebApiModuleTest + { + [Test] + public async Task JsonData_ReturnsOk() + { + var model = new Person { Key = 10, Name = "Test" }; + var payloadJson = new StringContent( + Json.Serialize(model), + System.Text.Encoding.UTF8, + MimeType.Json); + + var response = await Client.PostAsync("/api/regex", payloadJson); + + var result = Json.Deserialize(await response.Content.ReadAsStringAsync()); + Assert.IsNotNull(result); + Assert.AreEqual(model.Name, result.Name); + } + } + + public class Http405 : WebApiModuleTest + { + [Test] + public async Task ValidPathInvalidMethod_Returns405() + { + var request = new HttpRequestMessage(HttpMethod.Delete, "/api/regex"); + + var response = await Client.SendAsync(request); + + Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + } + + public class QueryData : WebApiModuleTest + { + [Test] + public async Task QueryDataAttribute_ReturnsCorrectValues() + { + var result = await Client.GetAsync($"/api/{TestController.QueryTestPath}?a=first&one=1&a=second&two=2&none&equal=&a[]=third"); + Assert.IsNotNull(result); + var data = await result.Content.ReadAsStringAsync(); + var dict = Json.Deserialize>(data); + Assert.IsNotNull(dict); + + Assert.AreEqual("1", dict["one"]); + Assert.AreEqual("2", dict["two"]); + Assert.AreEqual(string.Empty, dict["none"]); + Assert.AreEqual(string.Empty, dict["equal"]); + Assert.Throws(() => { + var three = dict["three"]; + }); + + var a = dict["a"] as IEnumerable; + Assert.NotNull(a); + var list = a.Cast().ToList(); + Assert.AreEqual(3, list.Count); + Assert.AreEqual("first", list[0]); + Assert.AreEqual("second", list[1]); + Assert.AreEqual("third", list[2]); + } + + [Test] + public async Task QueryFieldAttribute_ReturnsCorrectValue() + { + var value = Guid.NewGuid().ToString(); + var result = await Client.GetAsync($"/api/{TestController.QueryFieldTestPath}?id={value}"); + Assert.IsNotNull(result); + var returnedValue = await result.Content.ReadAsStringAsync(); + Assert.AreEqual(value, returnedValue); + } + } + + public class FormData : WebApiModuleTest + { + [TestCase("Id", "Id")] + [TestCase("Id[0]", "Id[1]")] + public async Task MultipleIndexedValues_ReturnsOk(string label1, string label2) + { + var content = new[] + { + new KeyValuePair("Test", "data"), + new KeyValuePair(label1, "1"), + new KeyValuePair(label2, "2"), + }; + + var formContent = new FormUrlEncodedContent(content); + + var result = await Client.PostAsync($"/api/{TestController.EchoPath}", formContent); + Assert.IsNotNull(result); + var data = await result.Content.ReadAsStringAsync(); + var obj = Json.Deserialize(data); + Assert.IsNotNull(obj); + Assert.AreEqual(content.First().Value, obj.Test); + Assert.AreEqual(2, obj.Id.Count); + Assert.AreEqual(content.Last().Value, obj.Id.Last()); + } + + [Test] + public async Task TestDictionaryFormData_ReturnsOk() + { + var content = new[] + { + new KeyValuePair("Test", "data"), + new KeyValuePair("Id", "1"), + }; + + var formContent = new FormUrlEncodedContent(content); + + var result = await Client.PostAsync("/api/" + TestController.EchoPath, formContent); + + Assert.IsNotNull(result); + var data = await result.Content.ReadAsStringAsync(); + var obj = Json.Deserialize>(data); + Assert.AreEqual(2, obj.Keys.Count); + + Assert.AreEqual(content.First().Key, obj.First().Key); + Assert.AreEqual(content.First().Value, obj.First().Value); + } + } + + internal class FormDataSample + { + public string Test { get; set; } + public List Id { get; set; } + } + + public class GetJsonData : WebApiModuleTest + { + [Test] + public Task WithRegexId_ReturnsOk() + => ValidatePersonAsync("/api/regex/1"); + + [Test] + public Task WithOptRegexIdAndValue_ReturnsOk() + => ValidatePersonAsync("/api/regexopt/1"); + + [Test] + public async Task WithOptRegexIdAndNonValue_ReturnsOk() + { + var jsonBody = await Client.GetStringAsync("/api/regexopt"); + var remoteList = Json.Deserialize>(jsonBody); + + Assert.AreEqual( + PeopleRepository.Database.Count, + remoteList.Count, + "Remote list count equals local list"); + } + + [Test] + public Task WithRegexDate_ReturnsOk() + { + var person = PeopleRepository.Database.First(); + return ValidatePersonAsync($"/api/regexdate/{person.DoB:yyyy-MM-dd}"); + } + + [Test] + public Task WithRegexWithTwoParams_ReturnsOk() + { + var person = PeopleRepository.Database.First(); + return ValidatePersonAsync($"/api/regextwo/{person.MainSkill}/{person.Age}"); + } + + [Test] + public Task WithRegexWithOptionalParams_ReturnsOk() + { + var person = PeopleRepository.Database.First(); + return ValidatePersonAsync($"/api/regexthree/{person.MainSkill}"); + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/WebServerTest.cs b/test/EmbedIO.Tests/WebServerTest.cs new file mode 100644 index 000000000..46c4a2be1 --- /dev/null +++ b/test/EmbedIO.Tests/WebServerTest.cs @@ -0,0 +1,204 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO.Actions; +using EmbedIO.Tests.TestObjects; +using EmbedIO.WebApi; +using NUnit.Framework; +using Swan; +using Swan.Formatters; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class WebServerTest + { + private const int Port = 88; + private const string Prefix = "http://localhost:9696"; + + private static string[] GetMultiplePrefixes() + => new[] { "http://localhost:9696", "http://localhost:9697", "http://localhost:9698" }; + + [SetUp] + public void Setup() + { + // TODO: Unregister console logger + // Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; + } + + public class Constructors : WebServerTest + { + [Test] + public void DefaultConstructor() + { + var instance = new WebServer(); + Assert.IsNotNull(instance.Listener, "It has a HttpListener"); + } + + [Test] + public void ConstructorWithPort() + { + var instance = new WebServer(Port); + Assert.IsNotNull(instance.Listener, "It has a HttpListener"); + } + + [Test] + public void ConstructorWithSinglePrefix() + { + var instance = new WebServer(Prefix); + Assert.IsNotNull(instance.Listener, "It has a HttpListener"); + } + + [Test] + public void ConstructorWithMultiplePrefixes() + { + var instance = new WebServer(GetMultiplePrefixes()); + Assert.IsNotNull(instance.Listener, "It has a HttpListener"); + Assert.AreEqual(3, instance.Listener.Prefixes.Count); + } + } + + public class TaskCancellation : WebServerTest + { + [Test] + public void WithCancellationRequested_ExitsSuccessfully() + { + var instance = new WebServer("http://localhost:9696"); + + var cts = new CancellationTokenSource(); + var task = instance.RunAsync(cts.Token); + cts.Cancel(); + + task.Await(); + instance.Dispose(); + + Assert.IsTrue(task.IsCompleted); + } + } + + public class Modules : WebServerTest + { + [Test] + public void RegisterModule() + { + var instance = new WebServer(); + instance.Modules.Add(nameof(WebApiModule), new WebApiModule("/")); + + Assert.AreEqual(instance.Modules.Count, 1, "It has one module"); + } + } + + public class General : WebServerTest + { + [Test] + public void ExceptionText() + { + Assert.ThrowsAsync(async () => + { + var url = Resources.GetServerAddress(); + + using (var instance = new WebServer(url)) + { + instance.Modules.Add(nameof(ActionModule), new ActionModule(_ => throw new InvalidOperationException("Error"))); + + var runTask = instance.RunAsync(); + var request = new HttpClient(); + await request.GetStringAsync(url); + } + }); + } + + [Test] + public void EmptyModules_NotFoundStatusCode() + { + Assert.ThrowsAsync(async () => + { + var url = Resources.GetServerAddress(); + + using (var instance = new WebServer(url)) + { + var runTask = instance.RunAsync(); + var request = new HttpClient(); + await request.GetStringAsync(url); + } + }); + } + + [TestCase("iso-8859-1")] + [TestCase("utf-8")] + [TestCase("utf-16")] + public async Task EncodingTest(string encodeName) + { + var url = Resources.GetServerAddress(); + + using (var instance = new WebServer(url)) + { + instance.OnPost(ctx => + { + var encoding = Encoding.GetEncoding("UTF-8"); + + try + { + var encodeValue = + ctx.Request.ContentType.Split(';') + .FirstOrDefault(x => + x.Trim().StartsWith("charset", StringComparison.OrdinalIgnoreCase)) + ? + .Split('=') + .Skip(1) + .FirstOrDefault()? + .Trim(); + encoding = Encoding.GetEncoding(encodeValue ?? throw new InvalidOperationException()); + } + catch + { + Assert.Inconclusive("Invalid encoding in system"); + } + + return ctx.SendDataAsync(new EncodeCheck + { + Encoding = encoding.EncodingName, + IsValid = ctx.Request.ContentEncoding.EncodingName == encoding.EncodingName, + }); + }); + + var runTask = instance.RunAsync(); + + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders.Accept + .Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(MimeType.Json)); + + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent( + "POST DATA", + Encoding.GetEncoding(encodeName), + MimeType.Json), + }; + + using (var response = await client.SendAsync(request)) + { + var data = await response.Content.ReadAsStringAsync(); + Assert.IsNotNull(data, "Data is not empty"); + var model = Json.Deserialize(data); + + Assert.IsNotNull(model); + Assert.IsTrue(model.IsValid); + } + } + } + } + + internal class EncodeCheck + { + public string Encoding { get; set; } + + public bool IsValid { get; set; } + } + } + } +} \ No newline at end of file diff --git a/test/EmbedIO.Tests/WebSocketModuleTest.cs b/test/EmbedIO.Tests/WebSocketModuleTest.cs new file mode 100644 index 000000000..e38729d06 --- /dev/null +++ b/test/EmbedIO.Tests/WebSocketModuleTest.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using EmbedIO.Tests.TestObjects; +using NUnit.Framework; +using Swan.Formatters; + +namespace EmbedIO.Tests +{ + [TestFixture] + public class WebSocketModuleTest : EndToEndFixtureBase + { + public WebSocketModuleTest() + : base(false) + { + } + + protected override void OnSetUp() + { + Server + .WithModule(new TestWebSocket("/test")) + .WithModule(new BigDataWebSocket("/bigdata")) + .WithModule(new CloseWebSocket("/close")); + } + + [Test] + public async Task TestConnectWebSocket() + { + var websocketUrl = new Uri(WebServerUrl.Replace("http", "ws") + "test"); + + var clientSocket = new System.Net.WebSockets.ClientWebSocket(); + await clientSocket.ConnectAsync(websocketUrl, default); + + Assert.AreEqual( + System.Net.WebSockets.WebSocketState.Open, + clientSocket.State, + $"Connection should be open, but the status is {clientSocket.State} - {websocketUrl}"); + + var buffer = new ArraySegment(Encoding.UTF8.GetBytes("HOLA")); + await clientSocket.SendAsync(buffer, System.Net.WebSockets.WebSocketMessageType.Text, true, default); + + Assert.AreEqual(await ReadString(clientSocket), "HELLO"); + } + + [Test] + public async Task TestSendBigDataWebSocket() + { + var webSocketUrl = new Uri($"{WebServerUrl.Replace("http", "ws")}bigdata"); + + var clientSocket = new System.Net.WebSockets.ClientWebSocket(); + await clientSocket.ConnectAsync(webSocketUrl, default).ConfigureAwait(false); + + var buffer = new ArraySegment(Encoding.UTF8.GetBytes("HOLA")); + await clientSocket.SendAsync(buffer, System.Net.WebSockets.WebSocketMessageType.Text, true, default).ConfigureAwait(false); + + var json = await ReadString(clientSocket).ConfigureAwait(false); + Assert.AreEqual(Json.Serialize(BigDataWebSocket.BigDataObject), json); + } + + [Test] + public async Task TestWithDifferentCloseResponse() + { + var webSocketUrl = new Uri($"{WebServerUrl.Replace("http", "ws")}close"); + + var clientSocket = new System.Net.WebSockets.ClientWebSocket(); + await clientSocket.ConnectAsync(webSocketUrl, default).ConfigureAwait(false); + + var buffer = new ArraySegment(new byte[8192]); + var result = await clientSocket.ReceiveAsync(buffer, default).ConfigureAwait(false); + + Assert.IsTrue(result.CloseStatus.HasValue); + Assert.IsTrue(result.CloseStatus.Value == System.Net.WebSockets.WebSocketCloseStatus.InvalidPayloadData); + } + + protected static async Task ReadString(System.Net.WebSockets.ClientWebSocket ws) + { + var buffer = new ArraySegment(new byte[8192]); + + using (var ms = new MemoryStream()) + { + System.Net.WebSockets.WebSocketReceiveResult result; + + do + { + result = await ws.ReceiveAsync(buffer, default); + ms.Write(buffer.Array, buffer.Offset, result.Count); + } + while (!result.EndOfMessage); + + return Encoding.UTF8.GetString(ms.ToArray()); + } + } + } +} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/AuthModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/AuthModuleTest.cs deleted file mode 100644 index 63e48903b..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/AuthModuleTest.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using Modules; - using NUnit.Framework; - using System; - using System.Net; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class AuthModuleTest : FixtureBase - { - public AuthModuleTest() - : base(ws => - { - ws.RegisterModule(new AuthModule("root", "password1234")); - ws.RegisterModule(new FallbackModule((ctx, ct) => ctx.JsonResponseAsync("OK", ct))); - }, - RoutingStrategy.Wildcard, - true) - { - // placeholder - } - - [Test] - public async Task RequestWithValidCredentials_ReturnsOK() - { - var request = new TestHttpRequest(WebServerUrl); - var byteArray = Encoding.ASCII.GetBytes("root:password1234"); - var authData = new System.Net.Http.Headers.AuthenticationHeaderValue("basic", - Convert.ToBase64String(byteArray)); - request.Headers.Add("Authorization", authData.ToString()); - - using (var response = await SendAsync(request)) - { - Assert.AreEqual((int)HttpStatusCode.OK, response.StatusCode, "Status Code OK"); - } - } - - [Test] - public async Task RequestWithInvalidCredentials_ReturnsUnauthorized() - { - var request = new TestHttpRequest(WebServerUrl); - var byteArray = Encoding.ASCII.GetBytes("root:password1233"); - var authData = new System.Net.Http.Headers.AuthenticationHeaderValue("basic", - Convert.ToBase64String(byteArray)); - request.Headers.Add("Authorization", authData.ToString()); - - using (var response = await SendAsync(request)) - { - Assert.AreEqual((int)HttpStatusCode.Unauthorized, response.StatusCode, "Status Code Unauthorized"); - } - } - - [Test] - public async Task RequestWithNoAuthorizationHeader_ReturnsUnauthorized() - { - var request = new TestHttpRequest(WebServerUrl); - - using (var response = await SendAsync(request)) - { - Assert.AreEqual((int)HttpStatusCode.Unauthorized, response.StatusCode, "Status Code Unauthorized"); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs deleted file mode 100644 index eb83b5a32..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using Modules; - using NUnit.Framework; - using Swan.Formatters; - using System.Net; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class CorsModuleTest : FixtureBase - { - private static readonly object TestObj = new { Message = "OK" }; - - public CorsModuleTest() - : base( - ws => - { - ws.EnableCors( - "http://client.cors-api.appspot.com,http://unosquare.github.io,http://run.plnkr.co", - "content-type", - "post,get"); - - ws.RegisterModule(new WebApiModule()); - ws.Module().RegisterController(); - ws.RegisterModule(new FallbackModule((ctx, ct) => ctx.JsonResponseAsync(TestObj, ct))); - }, - RoutingStrategy.Wildcard, - true) - { - // placeholder - } - - [Test] - public async Task RequestFallback_ReturnsJsonObject() - { - var jsonBody = await GetString("invalidpath"); - - Assert.AreEqual(Json.Serialize(TestObj), jsonBody, "Same content"); - } - - [Test] - public async Task RequestOptionsVerb_ReturnsOK() - { - var request = new TestHttpRequest(WebServerUrl + TestController.GetPath, HttpVerbs.Options); - request.Headers.Add(HttpHeaders.Origin, "http://unosquare.github.io"); - request.Headers.Add(HttpHeaders.AccessControlRequestMethod, "post"); - request.Headers.Add(HttpHeaders.AccessControlRequestHeaders, "content-type"); - - using (var response = await SendAsync(request)) - { - Assert.AreEqual((int) HttpStatusCode.OK, response.StatusCode, "Status Code OK"); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/DirectoryBrowserTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/DirectoryBrowserTest.cs deleted file mode 100644 index 6d0b602f3..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/DirectoryBrowserTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using NUnit.Framework; - using System.Threading.Tasks; - using Modules; - using TestObjects; - using Constants; - - [TestFixture] - public class DirectoryBrowserTest : FixtureBase - { - public DirectoryBrowserTest() - : base(ws => ws.RegisterModule(new StaticFilesModule(TestHelper.SetupStaticFolder(false), true)), - RoutingStrategy.Wildcard, - true) - { - } - - public class Browse : DirectoryBrowserTest - { - [Test] - public async Task Root_ReturnsFilesList() - { - var htmlContent = await GetString(string.Empty); - - Assert.IsNotEmpty(htmlContent); - - foreach (var file in TestHelper.RandomHtmls) - Assert.IsTrue(htmlContent.Contains(file)); - } - - [Test] - public async Task Subfolder_ReturnsFilesList() - { - var htmlContent = await GetString("sub"); - - Assert.IsNotEmpty(htmlContent); - - foreach (var file in TestHelper.RandomHtmls) - Assert.IsTrue(htmlContent.Contains(file)); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/EasyRoutesTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/EasyRoutesTest.cs deleted file mode 100644 index 1a13cad3a..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/EasyRoutesTest.cs +++ /dev/null @@ -1,137 +0,0 @@ -#pragma warning disable 4014 -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System.Threading.Tasks; - using NUnit.Framework; - - [TestFixture] - public class EasyRoutesTest - { - private const string Ok = "Ok"; - - [Test] - public async Task AddOnAny_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnAny((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - Assert.AreEqual(Ok, await server.GetClient().GetAsync()); - } - } - - [Test] - public async Task AddOnGet_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnGet((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - Assert.AreEqual(Ok, await server.GetClient().GetAsync()); - } - } - - [Test] - public async Task AddOnPost_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnPost((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Post)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - - [Test] - public async Task AddOnPut_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnPut((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Put)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - - [Test] - public async Task AddOnHead_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnHead((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Head)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - - [Test] - public async Task AddOnDelete_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnDelete((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Delete)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - - [Test] - public async Task AddOnOptions_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnOptions((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Options)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - - [Test] - public async Task AddOnPatch_ResponseOK() - { - using (var server = new TestWebServer()) - { - server - .OnPatch((ctx, ct) => ctx.StringResponseAsync(Ok, cancellationToken: ct)); - - server.RunAsync(); - - var response = await server.GetClient().SendAsync(new TestHttpRequest(Constants.HttpVerbs.Patch)); - - Assert.AreEqual(Ok, response.GetBodyAsString()); - } - } - } -} -#pragma warning restore 4014 diff --git a/test/Unosquare.Labs.EmbedIO.Tests/ExtensionTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/ExtensionTest.cs deleted file mode 100644 index 42b0b8fbe..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/ExtensionTest.cs +++ /dev/null @@ -1,119 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using System.Collections.Generic; - using System.IO; - using NUnit.Framework; - using System.Threading.Tasks; - using Constants; - - [TestFixture] - public class GzipTest - { - private readonly byte[] _buffer = System.Text.Encoding.UTF8.GetBytes("THIS IS DATA"); - - [TestCase(CompressionMethod.Gzip)] - [TestCase(CompressionMethod.Deflate)] - [TestCase(CompressionMethod.None)] - public async Task Compress(CompressionMethod method) - { - using (var ms = new MemoryStream(_buffer)) - { - var compressBuffer = await ms.CompressAsync(method); - - Assert.IsNotNull(compressBuffer); - - var decompressBuffer = await compressBuffer.CompressAsync(method, System.IO.Compression.CompressionMode.Decompress); - - Assert.AreEqual(decompressBuffer.ToArray(), _buffer); - } - } - } - - [TestFixture] - public class RequestWildcard - { - [TestCase("/data/1", new[] {"1"})] - [TestCase("/data/1/2", new[] {"1", "2"})] - public void UrlParamsWithLastParams(string urlMatch, string[] expected) - { - var result = urlMatch.RequestWildcardUrlParams("/data/*"); - Assert.AreEqual(expected.Length, result.Length); - Assert.AreEqual(expected[0], result[0]); - } - - [TestCase("/1/data", new[] {"1"})] - [TestCase("/1/2/data", new[] {"1", "2"})] - public void UrlParamsWithInitialParams(string urlMatch, string[] expected) - { - var result = urlMatch.RequestWildcardUrlParams("/*/data"); - Assert.AreEqual(expected.Length, result.Length); - Assert.AreEqual(expected[0], result[0]); - } - - [TestCase("/api/1/data", new[] {"1"})] - [TestCase("/api/1/2/data", new[] {"1", "2"})] - public void UrlParamsWithMiddleParams(string urlMatch, string[] expected) - { - var result = urlMatch.RequestWildcardUrlParams("/api/*/data"); - Assert.AreEqual(expected.Length, result.Length); - Assert.AreEqual(expected[0], result[0]); - } - } - - [TestFixture] - public class RequestRegex - { - private const string DefaultId = "id"; - - [Test] - public void UrlParamsWithLastParams() - { - var result = "/data/1".RequestRegexUrlParams("/data/{id}"); - var expected = new Dictionary {{DefaultId, "1"}}; - - Assert.IsTrue(result.ContainsKey(DefaultId)); - Assert.AreEqual(expected[DefaultId], result[DefaultId]); - } - - [Test] - public void UrlParamsWithOptionalLastParams() - { - var result = "/data/1".RequestRegexUrlParams("/data/{id?}"); - var expected = new Dictionary {{DefaultId, "1"}}; - - Assert.IsTrue(result.ContainsKey(DefaultId)); - Assert.AreEqual(expected[DefaultId], result[DefaultId]); - } - - [Test] - public void UrlParamsWithOptionalLastParamsNullable() - { - var result = "/data/".RequestRegexUrlParams("/data/{id?}"); - var expected = new Dictionary {{DefaultId, string.Empty}}; - - Assert.IsTrue(result.ContainsKey(DefaultId)); - Assert.AreEqual(expected[DefaultId], result[DefaultId]); - } - - [Test] - public void UrlParamsWithMultipleParams() - { - var result = "/data/1/2".RequestRegexUrlParams("/data/{id}/{anotherId}"); - var expected = new Dictionary {{DefaultId, "1"}, {"anotherId", "2"}}; - - Assert.IsTrue(result.ContainsKey(DefaultId)); - Assert.AreEqual(expected[DefaultId], result[DefaultId]); - - Assert.IsTrue(result.ContainsKey("anotherId")); - Assert.AreEqual(expected["anotherId"], result["anotherId"]); - } - - [Test] - public void UrlParamsWithoutParams() - { - var result = "/data/".RequestRegexUrlParams("/data/"); - - Assert.IsTrue(result.Keys.Count == 0); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/FixtureBase.cs b/test/Unosquare.Labs.EmbedIO.Tests/FixtureBase.cs deleted file mode 100644 index d3f0ebc7b..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/FixtureBase.cs +++ /dev/null @@ -1,110 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using NUnit.Framework; - using System; - using System.Net.Http; - using System.Threading.Tasks; - using TestObjects; - - public abstract class FixtureBase : IDisposable - { - private readonly Action _builder; - private readonly bool _useTestWebServer; - private readonly RoutingStrategy _routeStrategy; - - protected FixtureBase(Action builder, RoutingStrategy routeStrategy = RoutingStrategy.Regex, bool useTestWebServer = false) - { - Swan.Terminal.Settings.GlobalLoggingMessageType = Swan.LogMessageType.None; - - _builder = builder; - _routeStrategy = routeStrategy; - _useTestWebServer = useTestWebServer; - } - - ~FixtureBase() - { - Dispose(false); - } - - public string WebServerUrl { get; private set; } - - public IWebServer WebServerInstance { get; private set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [SetUp] - public void Init() - { - WebServerUrl = Resources.GetServerAddress(); - WebServerInstance = _useTestWebServer - ? (IWebServer)new TestWebServer(_routeStrategy) - : new WebServer(WebServerUrl, _routeStrategy); - - _builder(WebServerInstance); - OnAfterInit(); - WebServerInstance.RunAsync(); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; - - (WebServerInstance as IDisposable)?.Dispose(); - } - - protected virtual void OnAfterInit() - { - } - - [TearDown] - public void Kill() - { - Task.Delay(500).Wait(); - (WebServerInstance as IDisposable)?.Dispose(); - } - - public async Task GetString(string partialUrl = "") - { - if (WebServerInstance is TestWebServer testWebServer) - return await testWebServer.GetClient().GetAsync(partialUrl); - - using (var client = new HttpClient()) - { - var uri = new Uri(new Uri(WebServerUrl), partialUrl); - - return await client.GetStringAsync(uri); - } - } - - public async Task SendAsync(TestHttpRequest request) - { - if (WebServerInstance is TestWebServer testWebServer) - return await testWebServer.GetClient().SendAsync(request); - - using (var client = new HttpClient()) - { - var response = await client.SendAsync(request.ToHttpRequestMessage()); - - return response.ToTestHttpResponse(); - } - } - } - - internal static class TestExtensions - { - public static HttpRequestMessage ToHttpRequestMessage(this TestHttpRequest request) - { - return new HttpRequestMessage(); - } - - public static TestHttpResponse ToTestHttpResponse(this HttpResponseMessage response) - { - return new TestHttpResponse(); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/FluentTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/FluentTest.cs deleted file mode 100644 index a543870a4..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/FluentTest.cs +++ /dev/null @@ -1,164 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Modules; - using NUnit.Framework; - using System; - using System.Collections.Generic; - using System.Reflection; - using TestObjects; - - [TestFixture] - public class FluentTest - { - private readonly WebServer _nullWebServer = null; - private readonly Dictionary _commonPaths = new Dictionary - { - {"/Server/web", TestHelper.SetupStaticFolder()}, - {"/Server/api", TestHelper.SetupStaticFolder()}, - {"/Server/database", TestHelper.SetupStaticFolder()}, - }; - - private string _rootPath; - private string _webServerUrl; - - [SetUp] - public void Init() - { - Swan.Terminal.Settings.DisplayLoggingMessageType = Swan.LogMessageType.None; - - _webServerUrl = Resources.GetServerAddress(); - _rootPath = TestHelper.SetupStaticFolder(); - } - - [Test] - public void FluentWithStaticFolder() - { - var webServer = WebServer.Create(_webServerUrl) - .WithLocalSession() - .WithStaticFolderAt(_rootPath); - - Assert.AreEqual(webServer.Modules.Count, 2, "It has 2 modules loaded"); - Assert.IsNotNull(webServer.Module(), "It has StaticFilesModule"); - - Assert.AreEqual( - webServer.Module().FileSystemPath, - _rootPath, - "StaticFilesModule root path is equal to RootPath"); - } - - [Test] - public void FluentWithWebApi() - { - var webServer = WebServer.Create(_webServerUrl) - .WithWebApi(typeof(FluentTest).GetTypeInfo().Assembly); - - Assert.AreEqual(webServer.Modules.Count, 1, "It has 1 modules loaded"); - Assert.IsNotNull(webServer.Module(), "It has WebApiModule"); - Assert.AreEqual(webServer.Module().ControllersCount, 4, "It has four controllers"); - - (webServer as IDisposable)?.Dispose(); - } - - [Test] - public void FluentWithWebSockets() - { - var webServer = WebServer.Create(_webServerUrl) - .WithWebSocket(typeof(FluentTest).GetTypeInfo().Assembly); - - Assert.AreEqual(webServer.Modules.Count, 1, "It has 1 modules loaded"); - Assert.IsNotNull(webServer.Module(), "It has WebSocketsModule"); - - (webServer as IDisposable)?.Dispose(); - } - - [Test] - public void FluentLoadWebApiControllers() - { - var webServer = WebServer.Create(_webServerUrl) - .WithWebApi(); - webServer.Module().LoadApiControllers(typeof(FluentTest).GetTypeInfo().Assembly); - - Assert.AreEqual(webServer.Modules.Count, 1, "It has 1 modules loaded"); - Assert.IsNotNull(webServer.Module(), "It has WebApiModule"); - Assert.AreEqual(webServer.Module().ControllersCount, 4, "It has four controllers"); - - (webServer as IDisposable)?.Dispose(); - } - - [Test] - public void FluentWithStaticFolderArgumentException() - { - Assert.Throws(() => - _nullWebServer.WithStaticFolderAt(TestHelper.SetupStaticFolder())); - } - - [Test] - public void FluentWithVirtualPaths() - { - var webServer = WebServer.Create(_webServerUrl) - .WithVirtualPaths(_commonPaths); - - Assert.IsNotNull(webServer); - Assert.AreEqual(webServer.Modules.Count, 1, "It has 1 modules loaded"); - Assert.IsNotNull(webServer.Module(), "It has StaticFilesModule"); - Assert.AreEqual(webServer.Module().VirtualPaths.Count, 3, "It has 3 Virtual Paths"); - } - - [Test] - public void FluentWithVirtualPathsWebServerNull_ThrowsArgumentException() - { - Assert.Throws(() => - _nullWebServer.WithVirtualPaths(_commonPaths)); - } - - [Test] - public void FluentWithLocalSessionWebServerNull_ThrowsArgumentException() - { - Assert.Throws(() => _nullWebServer.WithLocalSession()); - } - - [Test] - public void FluentWithWebApiArgumentException() - { - Assert.Throws(() => _nullWebServer.WithWebApi()); - } - - [Test] - public void FluentWithWebSocketArgumentException() - { - Assert.Throws(() => _nullWebServer.WithWebSocket()); - } - - [Test] - public void FluentLoadApiControllersWebServerArgumentException() - { - Assert.Throws(() => _nullWebServer.LoadApiControllers()); - } - - [Test] - public void FluentLoadApiControllersWebApiModuleArgumentException() - { - WebApiModule webApi = null; - - Assert.Throws(() => webApi.LoadApiControllers()); - } - - [Test] - public void FluentLoadWebSocketsArgumentException() - { - Assert.Throws(() => _nullWebServer.LoadWebSockets()); - } - - [Test] - public void FluentEnableCorsArgumentException() - { - Assert.Throws(() => _nullWebServer.EnableCors()); - } - - [Test] - public void FluentWithWebApiControllerTArgumentException() - { - Assert.Throws(() => _nullWebServer.WithWebApiController()); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/HttpsTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/HttpsTest.cs deleted file mode 100644 index 5288b2ecc..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/HttpsTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Swan; - using System; - using System.Security.Cryptography.X509Certificates; - using System.Net.Http; - using NUnit.Framework; - using System.Threading.Tasks; - - [TestFixture] - public class HttpsTest - { - private const string DefaultMessage = "HOLA"; - private const string HttpsUrl = "https://localhost:5555"; - - [Test] - public async Task OpenWebServerHttps_RetrievesIndex() - { - if (Runtime.OS != Swan.OperatingSystem.Windows) - Assert.Ignore("Only Windows"); - - // bypass certification validation - System.Net.ServicePointManager.ServerCertificateValidationCallback = (s, c, cert, x) => true; - - var options = new WebServerOptions(HttpsUrl) - { - AutoLoadCertificate = true, - Mode = HttpListenerMode.EmbedIO, - }; - - using (var webServer = new WebServer(options)) - { - webServer.OnAny((ctx, ct) => ctx.HtmlResponseAsync(DefaultMessage, cancellationToken: ct)); - - webServer.RunAsync(); - - using (var httpClientHandler = new HttpClientHandler()) - { - httpClientHandler.ServerCertificateCustomValidationCallback = (s, c, cert, x) => true; - using (var httpClient = new HttpClient(httpClientHandler)) - { - Assert.AreEqual(DefaultMessage, await httpClient.GetStringAsync(HttpsUrl)); - } - } - } - } - - [Test] - public void OpenWebServerHttpsWithLinuxOrMac_ThrowsInvalidOperation() - { - if (Runtime.OS == Swan.OperatingSystem.Windows) - Assert.Ignore("Ignore Windows"); - - var options = new WebServerOptions(HttpsUrl) - { - AutoLoadCertificate = true, - }; - - Assert.Throws(() => new WebServer(options)); - } - - [Test] - public void OpenWebServerHttpsWithoutCert_ThrowsInvalidOperation() - { - if (Runtime.OS != Swan.OperatingSystem.Windows) - Assert.Ignore("Only Windows"); - - var options = new WebServerOptions(HttpsUrl) - { - AutoRegisterCertificate = true, - }; - - Assert.Throws(() => new WebServer(options)); - } - - [Test] - public void OpenWebServerHttpsWithInvalidStore_ThrowsInvalidOperation() - { - if (Runtime.OS != Swan.OperatingSystem.Windows) - Assert.Ignore("Only Windows"); - - var options = new WebServerOptions(HttpsUrl) - { - AutoRegisterCertificate = true, - Certificate = new X509Certificate2(), - }; - - Assert.Throws(() => new WebServer(options)); - } - } -} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/IWebServerTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/IWebServerTest.cs deleted file mode 100644 index d59f343f7..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/IWebServerTest.cs +++ /dev/null @@ -1,89 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using NUnit.Framework; - using Swan.Formatters; - using TestObjects; - using System.Threading.Tasks; - using Modules; - - public class IWebServerTest - { - [Test] - public void SetupInMemoryWebServer_ReturnsValidInstance() - { - using (var webserver = new TestWebServer()) - { - Assert.IsNotNull(webserver); - } - } - - [Test] - public void RegisterWebModule_ReturnsValidInstance() - { - using (var webserver = new TestWebServer()) - { - webserver.RegisterModule(new FallbackModule((ctx, ct) => ctx.JsonResponseAsync(nameof(TestWebServer), ct))); - - Assert.AreEqual(1, webserver.Modules.Count); - } - } - - [Test] - public void UnregisterWebModule_ReturnsValidInstance() - { - using (var webserver = new TestWebServer()) - { - webserver.RegisterModule(new FallbackModule((ctx, ct) => ctx.JsonResponseAsync(nameof(TestWebServer), ct))); - webserver.UnregisterModule(typeof(FallbackModule)); - - Assert.AreEqual(0, webserver.Modules.Count); - } - } - - [Test] - public void RegisterSessionModule_ReturnsValidInstance() - { - using (var webserver = new TestWebServer()) - { - webserver.RegisterModule(new LocalSessionModule()); - - Assert.NotNull(webserver.SessionModule); - } - } - - [Test] - public void UnregisterSessionModule_ReturnsValidInstance() - { - using (var webserver = new TestWebServer()) - { - webserver.RegisterModule(new LocalSessionModule()); - webserver.UnregisterModule(typeof(LocalSessionModule)); - - Assert.IsNull(webserver.SessionModule); - } - } - - [Test] - public async Task RunsServerAndRequestData_ReturnsValidData() - { - using (var webserver = new TestWebServer()) - { - webserver.OnAny((ctx, ct) => ctx.JsonResponseAsync(new Person {Name = nameof(Person)}, ct)); - -#pragma warning disable 4014 - webserver.RunAsync(); -#pragma warning restore 4014 - - var client = webserver.GetClient(); - - var data = await client.GetAsync("/"); - Assert.IsNotNull(data); - - var person = Json.Deserialize(data); - Assert.IsNotNull(person); - - Assert.AreEqual(person.Name, nameof(Person)); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/LocalSessionModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/LocalSessionModuleTest.cs deleted file mode 100644 index bb81b8b54..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/LocalSessionModuleTest.cs +++ /dev/null @@ -1,187 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Modules; - using NUnit.Framework; - using System; - using System.Linq; - using System.Net; - using System.Net.Http; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class LocalSessionModuleTest : FixtureBase - { - public LocalSessionModuleTest() - : base(ws => - { - ws.RegisterModule(new LocalSessionModule { Expiration = TimeSpan.FromSeconds(1) }); - ws.RegisterModule(new StaticFilesModule(TestHelper.SetupStaticFolder())); - ws.RegisterModule(new WebApiModule()); - ws.Module().RegisterController(); - }, - Constants.RoutingStrategy.Wildcard) - { - } - - protected async Task ValidateCookie(HttpRequestMessage request, HttpClient client, HttpClientHandler handler) - { - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - } - - Assert.IsNotNull(handler.CookieContainer, "Cookies are not null"); - Assert.Greater(handler.CookieContainer.GetCookies(new Uri(WebServerUrl)).Count, - 0, - "Cookies are not empty"); - } - - protected async Task GetFile(string content) - { - using (var handler = new HttpClientHandler()) - { - handler.CookieContainer = new CookieContainer(); - - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - await ValidateCookie(request, client, handler); - Assert.AreNotEqual(content, handler.CookieContainer.GetCookieHeader(new Uri(WebServerUrl))); - } - } - } - - public class Sessions : LocalSessionModuleTest - { - [Test] - public void HasSessionModule() - { - Assert.IsNotNull(WebServerInstance.SessionModule, "Session module is not null"); - Assert.AreEqual(WebServerInstance.SessionModule.Handlers.Count, 1, "Session module has one handler"); - } - - [Test] - public async Task DeleteSession() - { - using (var handler = new HttpClientHandler()) - { - handler.CookieContainer = new CookieContainer(); - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, - WebServerUrl + TestLocalSessionController.PutData); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - var body = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(TestLocalSessionController.MyData, body); - } - - request = new HttpRequestMessage(HttpMethod.Get, - WebServerUrl + TestLocalSessionController.DeleteSession); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - var body = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(body, "Deleted"); - } - - request = new HttpRequestMessage(HttpMethod.Get, - WebServerUrl + TestLocalSessionController.GetData); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - var body = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(string.Empty, body); - } - } - } - } - - [Test] - public async Task GetDifferentSession() - { - using (var handler = new HttpClientHandler()) - { - handler.CookieContainer = new CookieContainer(); - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - await ValidateCookie(request, client, handler); - var content = handler.CookieContainer.GetCookieHeader(new Uri(WebServerUrl)); - await Task.Delay(TimeSpan.FromSeconds(1)); - - Task.WaitAll( - new[] - { - Task.Factory.StartNew(() => GetFile(content)), - Task.Factory.StartNew(() => GetFile(content)), - Task.Factory.StartNew(() => GetFile(content)), - Task.Factory.StartNew(() => GetFile(content)), - Task.Factory.StartNew(() => GetFile(content)), - }); - } - } - } - } - - public class Cookies : LocalSessionModuleTest - { - [Test] - public async Task RetrieveCookie() - { - using (var handler = new HttpClientHandler()) - { - handler.CookieContainer = new CookieContainer(); - - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, - WebServerUrl + TestLocalSessionController.GetCookie); - var uri = new Uri(WebServerUrl + TestLocalSessionController.GetCookie); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status OK"); - var responseCookies = handler.CookieContainer.GetCookies(uri).Cast(); - Assert.IsNotNull(responseCookies, "Cookies are not null"); - - Assert.Greater(responseCookies.Count(), 0, "Cookies are not empty"); - var cookieName = - responseCookies.FirstOrDefault(c => c.Name == TestLocalSessionController.CookieName); - Assert.AreEqual(TestLocalSessionController.CookieName, cookieName?.Name); - } - } - } - } - - [Test] - public async Task GetCookie() - { - using (var handler = new HttpClientHandler()) - { - handler.CookieContainer = new CookieContainer(); - - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - - await ValidateCookie(request, client, handler); - Assert.IsNotEmpty(handler.CookieContainer.GetCookieHeader(new Uri(WebServerUrl)), - "Cookie content is not null"); - } - } - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/MultipleStaticRootsFixture.cs b/test/Unosquare.Labs.EmbedIO.Tests/MultipleStaticRootsFixture.cs deleted file mode 100644 index c0585fed0..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/MultipleStaticRootsFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Modules; - using NUnit.Framework; - using System.Linq; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class MultipleStaticRootsFixture : FixtureBase - { - private static readonly string[] InstancesNames = {string.Empty, "A/", "B/", "C/", "A/C", "AAA/A/B/C/", "A/B/C"}; - - public MultipleStaticRootsFixture() - : base(ws => - ws.RegisterModule( - new StaticFilesModule(InstancesNames.ToDictionary(x => "/" + x, TestHelper.SetupStaticFolderInstance)) - { - UseRamCache = true, - }), - Constants.RoutingStrategy.Wildcard, - true) - { - } - - [Test] - public async Task FileContentsMatchInstanceName() - { - foreach (var item in InstancesNames) - { - var html = await GetString(item); - - Assert.AreEqual( - TestHelper.GetStaticFolderInstanceIndexFileContents(item), - html, - "index.html contents match instance name"); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/RegexRoutingTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/RegexRoutingTest.cs deleted file mode 100644 index 2b888f5a3..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/RegexRoutingTest.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using NUnit.Framework; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class RegexRoutingTest : FixtureBase - { - public RegexRoutingTest() - : base(ws => ws.RegisterModule(new TestRegexModule()), Constants.RoutingStrategy.Regex, true) - { - } - - public class GetData : RegexRoutingTest - { - [Test] - public async Task GetDataWithoutRegex() - { - var call = await GetString("empty"); - - Assert.AreEqual("data", call); - } - - [Test] - public async Task GetDataWithRegex() - { - var call = await GetString("data/1"); - - Assert.AreEqual("1", call); - } - - [Test] - public async Task GetDataWithMultipleRegex() - { - var call = await GetString("data/1/dasdasasda"); - - Assert.AreEqual("dasdasasda", call); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/RegexWebApiModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/RegexWebApiModuleTest.cs deleted file mode 100644 index f1aa0ef71..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/RegexWebApiModuleTest.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using System.Collections.Generic; - using NUnit.Framework; - using Swan.Formatters; - using System.Linq; - using System.Net; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class RegexWebApiModuleTest : PersonFixtureBase - { - public RegexWebApiModuleTest() - : base(ws => ws.WithWebApiController(), RoutingStrategy.Regex, true) - { - } - - public class GetJsonData : RegexWebApiModuleTest - { - [Test] - public async Task WithoutRegex_ReturnsOk() - { - var jsonString = await GetString($"{TestRegexController.RelativePath}empty"); - - Assert.IsNotEmpty(jsonString); - } - - [Test] - public async Task BigData_ReturnsOk() - { - var jsonString = await GetString($"{TestRegexController.RelativePath}big"); - - Assert.IsNotEmpty(jsonString); - Assert.IsTrue(jsonString.StartsWith("[")); - Assert.IsTrue(jsonString.EndsWith("]")); - } - - [Test] - public async Task WithRegexId_ReturnsOk() - { - await ValidatePerson($"{TestRegexController.RelativePath}regex/1"); - } - - [Test] - public async Task WithOptRegexIdAndValue_ReturnsOk() - { - await ValidatePerson(TestRegexController.RelativePath + "regexopt/1"); - } - - [Test] - public async Task WithOptRegexIdAndNonValue_ReturnsOk() - { - var jsonBody = await GetString(TestRegexController.RelativePath + "regexopt"); - var remoteList = Json.Deserialize>(jsonBody); - - Assert.AreEqual( - remoteList.Count, - PeopleRepository.Database.Count, - "Remote list count equals local list"); - } - - [Test] - public async Task AsyncWithRegexId_ReturnsOk() - { - await ValidatePerson(TestRegexController.RelativePath + "regexAsync/1"); - } - - [Test] - public async Task WithRegexDate_ReturnsOk() - { - var person = PeopleRepository.Database.First(); - await ValidatePerson(TestRegexController.RelativePath + "regexdate/" + - person.DoB.ToString("yyyy-MM-dd")); - } - - [Test] - public async Task WithRegexWithTwoParams_ReturnsOk() - { - var person = PeopleRepository.Database.First(); - await ValidatePerson(TestRegexController.RelativePath + "regextwo/" + - person.MainSkill + "/" + person.Age); - } - - [Test] - public async Task WithRegexWithOptionalParams_ReturnsOk() - { - var person = PeopleRepository.Database.First(); - - await ValidatePerson(TestRegexController.RelativePath + "regexthree/" + - person.MainSkill); - } - } - - public class Http405 : RegexWebApiModuleTest - { - [Test] - public async Task ValidWebApiPathInvalidMethod_Returns405() - { - var request = new TestHttpRequest(WebServerUrl + TestRegexController.RelativePath + "regex/1", HttpVerbs.Delete); - - var response = await SendAsync(request); - - Assert.AreEqual((int)HttpStatusCode.MethodNotAllowed, response.StatusCode); - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/ResourceFilesModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/ResourceFilesModuleTest.cs deleted file mode 100644 index b358155da..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/ResourceFilesModuleTest.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using Modules; - using NUnit.Framework; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class ResourceFilesModuleTest : FixtureBase - { - public ResourceFilesModuleTest() - : base( - ws => - { - ws.RegisterModule(new ResourceFilesModule(typeof(ResourceFilesModuleTest).Assembly, - "Unosquare.Labs.EmbedIO.Tests.Resources")); - }, - RoutingStrategy.Wildcard, - true) - { - } - - [Test] - public async Task GetIndexFile_ReturnsValidContentFromResource() - { - var html = await GetString(); - - Assert.AreEqual(Resources.Index, html, "Same content index.html"); - } - - [Test] - public async Task GetSubfolderIndexFile_ReturnsValidContentFromResource() - { - var html = await GetString("sub/index.html"); - - Assert.AreEqual(Resources.SubIndex, html, "Same content index.html"); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs deleted file mode 100644 index 55f628568..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs +++ /dev/null @@ -1,555 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using Modules; - using NUnit.Framework; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Net; - using System.Net.Http; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class StaticFilesModuleTest : FixtureBase - { - private const string HeaderPragmaValue = "no-cache"; - - protected StaticFilesModuleTest(Func buildStaticFilesModule, string fallbackUrl = null) - : base(ws => - { - ws.RegisterModule(buildStaticFilesModule()); - if (fallbackUrl != null) - ws.RegisterModule(new FallbackModule(fallbackUrl)); - }, RoutingStrategy.Wildcard) - { - } - - protected StaticFilesModuleTest(string fallbackUrl) - : this(() => new StaticFilesModule(TestHelper.SetupStaticFolder()) { UseRamCache = true }, fallbackUrl) - { - } - - public StaticFilesModuleTest() - : this(null) - { - } - - private static async Task ValidatePayload(HttpResponseMessage response, int maxLength, int offset = 0) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.PartialContent, "Status Code PartialCode"); - - using (var ms = new MemoryStream()) - { - var responseStream = await response.Content.ReadAsStreamAsync(); - responseStream.CopyTo(ms); - var data = ms.ToArray(); - - Assert.IsNotNull(data, "Data is not empty"); - var subset = new byte[maxLength]; - var originalSet = TestHelper.GetBigData(); - Buffer.BlockCopy(originalSet, offset, subset, 0, maxLength); - Assert.IsTrue(subset.SequenceEqual(data)); - } - } - - public class UseVirtualPaths : StaticFilesModuleTest - { - private const string VirtualFolderName = "virtual"; - private const string VirtualizedFolderName = "html-virtualized"; - - public UseVirtualPaths() - : base(() => new StaticFilesModule(new Dictionary - { - {"/", TestHelper.SetupStaticFolder()}, - {"/" + VirtualFolderName, TestHelper.SetupStaticFolder(VirtualizedFolderName)}, - }) - {UseRamCache = true}) - { - } - - private string VirtualPathUrl { get; set; } - - protected override void OnAfterInit() - { - VirtualPathUrl = WebServerUrl + VirtualFolderName + "/"; - } - - [Test] - public async Task VirtualPathIndex() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, VirtualPathUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK on virtual path"); - - var html = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(Resources.Index, html, "Same content index.html on virtual path"); - - Assert.IsTrue(string.IsNullOrWhiteSpace(response.Headers.Pragma.ToString()), "Pragma empty"); - } - - WebServerInstance.Module().DefaultHeaders - .Add(HttpHeaders.Pragma, HeaderPragmaValue); - - request = new HttpRequestMessage(HttpMethod.Get, VirtualPathUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Status Code OK on virtual path"); - Assert.AreEqual(HeaderPragmaValue, response.Headers.Pragma.ToString()); - } - } - } - - [Test] - public async Task Issue68_MaliciousPath_GivesError404() - { - // Take the full path to a file that certainly exists, but is outside the virtualized folder - // (in this case, index.html in the "/" web folder) - var path = Path.Combine(TestHelper.RootPath(), StaticFilesModule.DefaultDocumentName); - // Add said path to a valid virtual path, resulting in "/virtual/C:\some\path" - var url = VirtualPathUrl + WebUtility.UrlEncode(path); - - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, url); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode, "Status Code 404 requesting malicious path"); - } - } - } - } - - public class UseFallback : StaticFilesModuleTest - { - public UseFallback() - : base("/") - { - } - - [Test] - public async Task FallbackIndex() - { - var html = await GetString("invalidpath"); - - Assert.AreEqual(Resources.Index, html, "Same content index.html"); - } - } - - public class GetFiles : StaticFilesModuleTest - { - [Test] - public async Task Index() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - var html = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(Resources.Index, html, "Same content index.html"); - - Assert.IsTrue(string.IsNullOrWhiteSpace(response.Headers.Pragma.ToString()), "Pragma empty"); - } - - WebServerInstance.Module().DefaultHeaders - .Add(HttpHeaders.Pragma, HeaderPragmaValue); - - request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - Assert.AreEqual(HeaderPragmaValue, response.Headers.Pragma.ToString()); - } - } - } - - [TestCase("sub/")] - [TestCase("sub")] - public async Task SubFolderIndex(string url) - { - var html = await GetString(url); - - Assert.AreEqual(Resources.SubIndex, html, $"Same content {url}"); - } - - [Test] - public async Task TestHeadIndex() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Head, WebServerUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - var html = await response.Content.ReadAsStringAsync(); - - Assert.IsEmpty(html, "Content Empty"); - } - } - } - - [Test] - public async Task FileWritable() - { - var endpoint = Resources.GetServerAddress(); - var root = Path.GetTempPath(); - var file = Path.Combine(root, "index.html"); - File.WriteAllText(file, Resources.Index); - - using (var server = new WebServer(endpoint)) - { - server.RegisterModule(new StaticFilesModule(root) {UseRamCache = false}); - var runTask = server.RunAsync(); - - using (var webClient = new HttpClient()) - { - var remoteFile = await webClient.GetStringAsync(endpoint); - File.WriteAllText(file, Resources.SubIndex); - - var remoteUpdatedFile = await webClient.GetStringAsync(endpoint); - File.WriteAllText(file, nameof(WebServer)); - - Assert.AreEqual(Resources.Index, remoteFile); - Assert.AreEqual(Resources.SubIndex, remoteUpdatedFile); - } - } - } - - [Test] - public async Task SensitiveFile() - { - var file = Path.GetTempPath() + Guid.NewGuid().ToString().ToLower(); - File.WriteAllText(file, string.Empty); - - Assert.IsTrue(File.Exists(file), "File was created"); - - if (File.Exists(file.ToUpper())) - { - Assert.Ignore("File-system is not case sensitive. Ignoring"); - } - - var htmlUpperCase = await GetString(TestHelper.UppercaseFile); - Assert.AreEqual(nameof(TestHelper.UppercaseFile), htmlUpperCase, "Same content upper case"); - - var htmlLowerCase = await GetString(TestHelper.LowercaseFile); - Assert.AreEqual(nameof(TestHelper.LowercaseFile), htmlLowerCase, "Same content lower case"); - } - - [Test] - public void InvalidFilePath_ThrowsArgumentException() - { - Assert.Throws(() => new StaticFilesModule("e:") {UseRamCache = false}); - } - } - - public class RegisterVirtualPath - { - [Test] - public void RegisterVirtualPaths() - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("/tmp", Path.GetTempPath()); - Assert.AreNotEqual(instance.VirtualPaths.Count, 0); - } - - [Test] - public void UnregisterVirtualPaths() - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("/tmp", Path.GetTempPath()); - Assert.AreNotEqual(instance.VirtualPaths.Count, 0); - instance.UnregisterVirtualPath("/tmp"); - Assert.AreEqual(instance.VirtualPaths.Count, 0); - } - - [Test] - public void RegisterExistingVirtualPath_ThrowsInvalidOperationException() - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("/tmp", Path.GetTempPath()); - Assert.AreNotEqual(instance.VirtualPaths.Count, 0); - - Assert.Throws(() => - instance.RegisterVirtualPath("/tmp", Path.GetTempPath())); - } - - [Test] - public void RegisterInvalidVirtualPath_ThrowsInvalidOperationException() - { - Assert.Throws(() => - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("tmp", Path.GetTempPath()); - }); - } - - [Test] - public void RegisterInvalidPhysicalPath_ThrowsInvalidOperationException() - { - Assert.Throws(() => - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("/tmp", @"e:*.dll"); - }); - } - } - - public class GetPartials : StaticFilesModuleTest - { - [Test] - public async Task Initial() - { - using (var client = new HttpClient()) - { - const int maxLength = 100; - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, maxLength - 1); - - using (var response = await client.SendAsync(request)) - { - await ValidatePayload(response, maxLength); - } - } - } - - [Test] - public async Task Middle() - { - using (var client = new HttpClient()) - { - const int offset = 50; - const int maxLength = 100; - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - request.Headers.Range = - new System.Net.Http.Headers.RangeHeaderValue(offset, maxLength + offset - 1); - - using (var response = await client.SendAsync(request)) - { - await ValidatePayload(response, maxLength, offset); - } - } - } - - [Test] - public async Task GetLastPart() - { - using (var client = new HttpClient()) - { - const int offset = 100; - const int maxLength = 100; - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - request.Headers.Range = - new System.Net.Http.Headers.RangeHeaderValue(offset, offset + maxLength - 1); - - using (var response = await client.SendAsync(request)) - { - await ValidatePayload(response, maxLength, offset); - } - } - } - - [Test] - public async Task NotPartial() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - using (var ms = new MemoryStream()) - { - var responseStream = await response.Content.ReadAsStreamAsync(); - responseStream.CopyTo(ms); - var data = ms.ToArray(); - - Assert.IsNotNull(data, "Data is not empty"); - Assert.IsTrue(TestHelper.GetBigData().SequenceEqual(data)); - } - } - } - } - } - - public class GetChunks : StaticFilesModuleTest - { - [Test] - public async Task GetEntireFileWithChunksUsingRange() - { - using (var client = new HttpClient()) - { - var originalSet = TestHelper.GetBigData(); - var requestHead = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - - using (var res = await client.SendAsync(requestHead)) - { - var remoteSize = await res.Content.ReadAsByteArrayAsync(); - Assert.AreEqual(remoteSize.Length, originalSet.Length); - - var buffer = new byte[remoteSize.Length]; - const int chunkSize = 100000; - for (var i = 0; i < (remoteSize.Length / chunkSize) + 1; i++) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - var top = (i + 1) * chunkSize; - - request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(i * chunkSize, - (top > remoteSize.Length ? remoteSize.Length : top) - 1); - - using (var response = await client.SendAsync(request)) - { - if (remoteSize.Length < top) - { - Assert.AreEqual( - response.StatusCode, - HttpStatusCode.PartialContent, - "Status Code PartialCode"); - } - - using (var ms = new MemoryStream()) - { - var stream = await response.Content.ReadAsStreamAsync(); - stream.CopyTo(ms); - var data = ms.ToArray(); - Buffer.BlockCopy(data, 0, buffer, i * chunkSize, data.Length); - } - } - } - - Assert.IsTrue(originalSet.SequenceEqual(buffer)); - } - } - } - - [Test] - public async Task GetInvalidChunk() - { - using (var client = new HttpClient()) - { - var originalSet = TestHelper.GetBigData(); - var requestHead = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - - using (var res = await client.SendAsync(requestHead)) - { - var remoteSize = await res.Content.ReadAsByteArrayAsync(); - Assert.AreEqual(remoteSize.Length, originalSet.Length); - - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + TestHelper.BigDataFile); - request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, remoteSize.Length + 10); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual( - response.StatusCode, - HttpStatusCode.RequestedRangeNotSatisfiable, - "Status Code RequestedRangeNotSatisfiable"); - - Assert.AreEqual(response.Content.Headers.ContentRange.Length, remoteSize.Length); - } - } - } - } - } - - public class CompressFile : StaticFilesModuleTest - { - [Test] - public async Task GetGzip() - { - using (var handler = new HttpClientHandler()) - { - handler.AutomaticDecompression = DecompressionMethods.GZip; - - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - var html = await response.Content.ReadAsStringAsync(); - Assert.IsNotNull(html, "Data is not empty"); - Assert.AreEqual(Resources.Index, html); - - // TODO: I need to fix this - //Assert.IsTrue(response.ContentEncoding.ToLower().Contains("gzip"), "Request is gziped"); - //var responseStream = new GZipStream(response.GetResponseStream(), CompressionMode.Decompress); - } - } - } - } - } - - public class Etag : StaticFilesModuleTest - { - [Test] - public async Task GetEtag() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - string eTag; - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); - - // Can't use response.Headers.Etag, it's always null - Assert.NotNull(response.Headers.FirstOrDefault(x => x.Key == "ETag"), "ETag is not null"); - eTag = response.Headers.First(x => x.Key == "ETag").Value.First(); - } - - var secondRequest = new HttpRequestMessage(HttpMethod.Get, WebServerUrl); - secondRequest.Headers.TryAddWithoutValidation(HttpHeaders.IfNotMatch, eTag); - - using (var response = await client.SendAsync(secondRequest)) - { - Assert.AreEqual(response.StatusCode, HttpStatusCode.NotModified, "Status Code NotModified"); - } - } - } - - public class DefaultExtension - { - [Test] - public void SetAndGetExtension() - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - Assert.IsNull(instance.DefaultExtension); - instance.DefaultExtension = ".xml"; - Assert.AreEqual(instance.DefaultExtension, ".xml"); - } - } - - public class RamCache - { - [Test] - public void UseRamCache() - { - var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - Assert.IsTrue(instance.UseRamCache); - instance.UseRamCache = false; - Assert.IsFalse(instance.UseRamCache); - } - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PersonFixtureBase.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PersonFixtureBase.cs deleted file mode 100644 index 52a1c1d1e..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/PersonFixtureBase.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using Constants; - using NUnit.Framework; - using Swan.Formatters; - using System; - using System.Linq; - using System.Threading.Tasks; - - public abstract class PersonFixtureBase : FixtureBase - { - protected PersonFixtureBase(Action builder, RoutingStrategy routeStrategy = RoutingStrategy.Regex, bool useTestWebServer = false) - : base(builder, routeStrategy, useTestWebServer) - { - } - - protected async Task ValidatePerson(string url) - { - var current = PeopleRepository.Database.First(); - - var jsonBody = await GetString(url); - - Assert.IsNotNull(jsonBody, "Json Body is not null"); - Assert.IsNotEmpty(jsonBody, "Json Body is not empty"); - - var item = Json.Deserialize(jsonBody); - - Assert.IsNotNull(item, "Json Object is not null"); - Assert.AreEqual(item.Name, current.Name, "Remote objects equality"); - } - } -} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestController.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestController.cs deleted file mode 100644 index 42a83cbf5..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestController.cs +++ /dev/null @@ -1,135 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using Constants; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using Modules; - - public class TestController : WebApiController - { - public const string RelativePath = "api/"; - public const string EchoPath = RelativePath + "echo/"; - public const string GetPath = RelativePath + "people/"; - public const string GetMiddlePath = RelativePath + "person/*/select"; - - public TestController(IHttpContext context) - : base(context) - { - } - - [WebApiHandler(HttpVerbs.Get, "/" + GetMiddlePath)] - public Task GetPerson() - { - try - { - // read the middle segment - var segment = Request.Url.Segments.Reverse().Skip(1) - .First() - .Replace("/", string.Empty); - - return CheckPerson(segment); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + GetPath + "*")] - public Task GetPeople() - { - try - { - // read the last segment - var lastSegment = Request.Url.Segments.Last(); - - // if it ends with a / means we need to list people - return lastSegment.EndsWith("/") - ? Ok(PeopleRepository.Database) - : CheckPerson(lastSegment); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Post, "/" + GetPath + "*")] - public Task PostPeople() - { - try - { - return Ok(async (x, ct) => - { - await Task.Delay(0, ct); - - return x; - }); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Post, "/" + EchoPath + "*")] - public async Task PostEcho() - { - try - { - var content = await HttpContext.RequestFormDataDictionaryAsync(); - - return await Ok(content); - } - catch (Exception ex) - { - return await InternalServerError(ex); - } - } - - private Task CheckPerson(string personKey) - { - if (int.TryParse(personKey, out var key) && PeopleRepository.Database.Any(p => p.Key == key)) - { - return Ok(PeopleRepository.Database.FirstOrDefault(p => p.Key == key)); - } - - throw new KeyNotFoundException($"Key Not Found: {personKey}"); - } - } - - public class TestControllerWithConstructor : WebApiController - { - public const string CustomHeader = "X-Custom"; - - public TestControllerWithConstructor(IHttpContext context, string name = "Test") - : base(context) - { - WebName = name; - } - - public string WebName { get; set; } - - [WebApiHandler(HttpVerbs.Get, "/name")] - public Task GetName() - { - Response.NoCache(); - return Ok(WebName); - } - - [WebApiHandler(HttpVerbs.Get, "/namePublic")] - public Task GetNamePublic() - { - Response.AddHeader("Cache-Control", "public"); - return Ok(WebName); - } - - public override void SetDefaultHeaders() - { - // do nothing with cache - Response.AddHeader(CustomHeader, WebName); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestHelper.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestHelper.cs deleted file mode 100644 index cc62e9c5c..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestHelper.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using Modules; - using System; - using System.IO; - using System.Linq; - using System.Reflection; - - public static class TestHelper - { - public const string BigDataFile = "bigdata.bin"; - - public const string SmallDataFile = "smalldata.bin"; - - public const string LowercaseFile = "abcdef.txt"; - - public const string UppercaseFile = "ABCDEF.txt"; - - public static string[] RandomHtmls = {"abc.html", "wkp.html", "zxy.html"}; - - private const string Placeholder = "This is a placeholder"; - - public static string RootPath(string folderName) - { - var assemblyPath = Path.GetDirectoryName(typeof(StaticFilesModuleTest).GetTypeInfo().Assembly.Location); - return Path.Combine(assemblyPath ?? throw new InvalidOperationException(), folderName); - } - - public static string RootPath() => RootPath("html"); - - public static byte[] GetBigData() => File.Exists(Path.Combine(RootPath(), BigDataFile)) - ? File.ReadAllBytes(Path.Combine(RootPath(), BigDataFile)) - : null; - - private static string SetupStaticFolderCore(string rootPath, bool onlyIndex = true) - { - if (!Directory.Exists(rootPath)) - Directory.CreateDirectory(rootPath); - - if (!Directory.Exists(Path.Combine(rootPath, "sub"))) - Directory.CreateDirectory(Path.Combine(rootPath, "sub")); - - var files = onlyIndex ? new[] {StaticFilesModule.DefaultDocumentName} : RandomHtmls; - - foreach (var file in files.Where(file => !File.Exists(Path.Combine(rootPath, file)))) - { - File.WriteAllText(Path.Combine(rootPath, file), Resources.Index); - } - - foreach (var file in files.Where(file => !File.Exists(Path.Combine(rootPath, "sub", file)))) - { - File.WriteAllText(Path.Combine(rootPath, "sub", file), Resources.SubIndex); - } - - // write only random htmls when onlyIndex is false - if (!onlyIndex) return rootPath; - - if (!File.Exists(Path.Combine(rootPath, BigDataFile))) - CreateTempBinaryFile(Path.Combine(rootPath, BigDataFile), 10); - - if (!File.Exists(Path.Combine(rootPath, SmallDataFile))) - CreateTempBinaryFile(Path.Combine(rootPath, SmallDataFile), 1); - - if (!File.Exists(Path.Combine(rootPath, LowercaseFile))) - File.WriteAllText(Path.Combine(rootPath, LowercaseFile), nameof(LowercaseFile)); - - if (!File.Exists(Path.Combine(rootPath, UppercaseFile))) - File.WriteAllText(Path.Combine(rootPath, UppercaseFile), nameof(UppercaseFile)); - - return rootPath; - } - - public static string SetupStaticFolder(bool onlyIndex = true) => SetupStaticFolderCore(RootPath(), onlyIndex); - - public static string SetupStaticFolder(string folderName, bool onlyIndex = true) => SetupStaticFolderCore(RootPath(folderName), onlyIndex); - - public static string GetStaticFolderInstanceIndexFileContents(string instanceName) => - string.IsNullOrWhiteSpace(instanceName) - ? Resources.Index - : Resources.Index.Replace(Placeholder, "Instance name is " + instanceName); - - public static string SetupStaticFolderInstance(string instanceName) - { - var folderName = instanceName.Replace('/', Path.DirectorySeparatorChar); - var location = Path.GetDirectoryName(typeof(StaticFilesModuleTest).GetTypeInfo().Assembly.Location) ?? - throw new InvalidOperationException(); - var folder = Path.Combine(location, folderName); - - if (!Directory.Exists(folder)) - Directory.CreateDirectory(folder); - - var fileName = Path.Combine(folder, StaticFilesModule.DefaultDocumentName); - - File.WriteAllText(fileName, GetStaticFolderInstanceIndexFileContents(instanceName)); - return folder; - } - - /// - /// Creates the temporary binary file. - /// - /// Name of the file. - /// The size in mb. - public static void CreateTempBinaryFile(string fileName, int sizeInMb) - { - // Note: block size must be a factor of 1MB to avoid rounding errors :) - const int blockSize = 1024 * 8; - const int blocksPerMb = (1024 * 1024) / blockSize; - var data = new byte[blockSize]; - - var rng = new Random(); - using (var stream = File.OpenWrite(fileName)) - { - // There - for (var i = 0; i < sizeInMb * blocksPerMb; i++) - { - rng.NextBytes(data); - stream.Write(data, 0, data.Length); - } - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestLocalSessionController.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestLocalSessionController.cs deleted file mode 100644 index 56bbc7d6c..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestLocalSessionController.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using Constants; - using System.Threading.Tasks; - using Modules; - - public class TestLocalSessionController : WebApiController - { - public const string DeleteSession = "deletesession"; - public const string PutData = "putdata"; - public const string GetData = "getdata"; - public const string GetCookie = "getcookie"; - - public const string MyData = "MyData"; - public const string CookieName = "MyCookie"; - - public TestLocalSessionController(IHttpContext context) - : base(context) - { - } - - [WebApiHandler(HttpVerbs.Get, "/getcookie")] - public Task GetCookieC() - { - var cookie = new System.Net.Cookie(CookieName, CookieName); - Response.Cookies.Add(cookie); - - return Ok(Response.Cookies[CookieName]); - } - - [WebApiHandler(HttpVerbs.Get, "/deletesession")] - public Task DeleteSessionC() - { - HttpContext.DeleteSession(); - - return Ok("Deleted"); - } - - [WebApiHandler(HttpVerbs.Get, "/putdata")] - public Task PutDataSession() - { - HttpContext.GetSession()?.Data.TryAdd("sessionData", MyData); - - return Ok(HttpContext.GetSession().Data["sessionData"].ToString()); - } - - [WebApiHandler(HttpVerbs.Get, "/getdata")] - public Task GetDataSession() => - Ok(HttpContext.GetSession().Data.TryGetValue("sessionData", out var data) - ? data.ToString() - : string.Empty); - - [WebApiHandler(HttpVerbs.Get, "/geterror")] - public bool GetError() => false; - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexController.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexController.cs deleted file mode 100644 index 76bab9bff..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexController.cs +++ /dev/null @@ -1,153 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - using Constants; - using Modules; - - public class TestRegexController : WebApiController - { - public const string RelativePath = "api/"; - - public TestRegexController(IHttpContext context) - : base(context) - { - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "big")] - public Task GetBigJson() => Ok(Enumerable.Range(1, 100).Select(x => new - { - x, - y = TimeZoneInfo.GetSystemTimeZones() - .Select(z => new { z.StandardName, z.DisplayName }), - })); - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "empty")] - public Task GetEmpty() => Ok(new { Ok = true }); - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regex")] - public Task GetPeople() - { - try - { - return Ok(PeopleRepository.Database); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regex/{id}")] - public Task GetPerson(int id) - { - try - { - return CheckPerson(id); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regexopt/{id?}")] - public Task GetPerson(int? id) - { - try - { - return id.HasValue ? CheckPerson(id.Value) : Ok(PeopleRepository.Database); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regexAsync/{id}")] - public Task GetPersonAsync(int id) - { - try - { - return CheckPerson(id); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regexdate/{date}")] - public Task GetPerson(DateTime date) - { - try - { - var item = PeopleRepository.Database.FirstOrDefault(p => p.DoB == date); - - if (item != null) - { - return Ok(item); - } - - throw new KeyNotFoundException($"Key Not Found: {date}"); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regextwo/{skill}/{age}")] - public Task GetPerson(string skill, int age) - { - try - { - var item = PeopleRepository.Database.FirstOrDefault(p => - string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase) && p.Age == age); - - if (item != null) - { - return Ok(item); - } - - throw new KeyNotFoundException($"Key Not Found: {skill}-{age}"); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - [WebApiHandler(HttpVerbs.Get, "/" + RelativePath + "regexthree/{skill}/{age?}")] - public Task GetOptionalPerson(string skill, int? age = null) - { - try - { - var item = age == null - ? PeopleRepository.Database.FirstOrDefault(p => string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase)) - : PeopleRepository.Database.FirstOrDefault(p => string.Equals(p.MainSkill, skill, StringComparison.CurrentCultureIgnoreCase) && p.Age == age); - - if (item != null) - { - return Ok(item); - } - - throw new KeyNotFoundException($"Key Not Found: {skill}-{age}"); - } - catch (Exception ex) - { - return InternalServerError(ex); - } - } - - private Task CheckPerson(int id) - { - var item = PeopleRepository.Database.FirstOrDefault(p => p.Key == id); - - if (item == null) throw new KeyNotFoundException($"Key Not Found: {id}"); - return Ok(item); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexModule.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexModule.cs deleted file mode 100644 index 457734fb8..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRegexModule.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System.Text; - using System.Threading.Tasks; - - public class TestRegexModule : WebModuleBase - { - public TestRegexModule() - { - AddHandler("/data/{id}", Constants.HttpVerbs.Any, (ctx, ct) => - { - var buffer = Encoding.UTF8.GetBytes(ctx.RequestRegexUrlParams("/data/{id}")["id"].ToString()); - ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); - - return Task.FromResult(true); - }); - - AddHandler("/data/{id}/{time}", Constants.HttpVerbs.Any, (ctx, ct) => - { - var buffer = Encoding.UTF8.GetBytes(ctx.RequestRegexUrlParams("/data/{id}/{time}")["time"].ToString()); - ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); - - return Task.FromResult(true); - }); - - AddHandler("/empty", Constants.HttpVerbs.Any, (ctx, ct) => - { - var buffer = Encoding.UTF8.GetBytes("data"); - ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); - - return Task.FromResult(true); - }); - } - - public override string Name => nameof(TestRoutingModule); - } -} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRoutingModule.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRoutingModule.cs deleted file mode 100644 index 53b00f0fb..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestRoutingModule.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - public class TestRoutingModule : WebModuleBase - { - public TestRoutingModule() - { - AddHandler("/data/*", Constants.HttpVerbs.Any, (ctx, ct) => - { - var buffer = Encoding.UTF8.GetBytes(ctx.RequestWildcardUrlParams("/data/*").LastOrDefault() ?? string.Empty); - ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); - - return Task.FromResult(true); - }); - - AddHandler("/empty", Constants.HttpVerbs.Any, (ctx, ct) => - { - var buffer = Encoding.UTF8.GetBytes("data"); - ctx.Response.OutputStream.Write(buffer, 0, buffer.Length); - - return Task.FromResult(true); - }); - } - - public override string Name => nameof(TestRoutingModule); - } -} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebModule.cs b/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebModule.cs deleted file mode 100644 index 039ae807a..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/TestObjects/TestWebModule.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests.TestObjects -{ - using System.Threading.Tasks; - using Constants; - - public class TestWebModule : WebModuleBase - { - public const string RedirectUrl = "redirect"; - public const string RedirectAbsoluteUrl = "redirectAbsolute"; - public const string AnotherUrl = "anotherUrl"; - - public TestWebModule() - { - AddHandler("/" + RedirectUrl, - HttpVerbs.Get, - (context, ct) => Task.FromResult(context.Redirect("/" + AnotherUrl, false))); - - AddHandler("/" + RedirectAbsoluteUrl, - HttpVerbs.Get, - (context, ct) => Task.FromResult(context.Redirect("/" + AnotherUrl))); - - AddHandler("/" + AnotherUrl, HttpVerbs.Get, (server, context) => Task.FromResult(true)); - } - - public override string Name => nameof(TestWebModule); - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj b/test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj deleted file mode 100644 index ac61743ef..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj +++ /dev/null @@ -1,47 +0,0 @@ - - - - Copyright (c) 2016-2019 - Unosquare - net472;netcoreapp2.2 - UnitTest - ..\..\StyleCop.Analyzers.ruleset - 7.3 - - - - false - - - - - - - - - - - - - - - - - - All - - - - - - - - - - - - - - - - - diff --git a/test/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs deleted file mode 100644 index 5e21682e0..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs +++ /dev/null @@ -1,214 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Modules; - using NUnit.Framework; - using Swan.Formatters; - using System.Collections.Generic; - using System.Linq; - using System.Net; - using System.Net.Http; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class WebApiModuleTest : PersonFixtureBase - { - public WebApiModuleTest() - : base(ws => ws.WithWebApiController(), Constants.RoutingStrategy.Wildcard) - { - } - - public class WebApiWithConstructor : WebApiModuleTest - { - [Test] - public async Task GetWebApiWithCustomHeader_ReturnsNameFromConstructor() - { - const string name = nameof(TestControllerWithConstructor); - - WebServerInstance.Module().RegisterController((ctx) => new TestControllerWithConstructor(ctx, name)); - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + "name"); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(name, response.Headers.FirstOrDefault(x => x.Key == TestControllerWithConstructor.CustomHeader).Value.FirstOrDefault()); - } - } - } - - [Test] - public async Task GetWebApiWithCacheControlPublic_ReturnsValidResponse() - { - WebServerInstance.Module().RegisterController((ctx) => new TestControllerWithConstructor(ctx)); - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + "namePublic"); - - using (var response = await client.SendAsync(request)) - { - Assert.IsTrue(response.Headers.CacheControl.Public, "Cache is public"); - - Assert.IsFalse(response.Headers.CacheControl.NoStore, "Cache is not No-Store"); - Assert.IsFalse(response.Headers.CacheControl.NoCache, "Cache is not No-Cache"); - Assert.IsFalse(response.Headers.CacheControl.MustRevalidate, "Cache is not Must-Revalidate"); - } - } - } - - [Test] - public async Task GetWebApiWithCacheControlDefault_ReturnsValidResponse() - { - WebServerInstance.Module().RegisterController((ctx) => new TestControllerWithConstructor(ctx)); - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Get, WebServerUrl + "name"); - - using (var response = await client.SendAsync(request)) - { - Assert.IsFalse(response.Headers.CacheControl.Public, "Cache is not public"); - - Assert.IsTrue(response.Headers.CacheControl.NoStore); - Assert.IsTrue(response.Headers.CacheControl.NoCache); - Assert.IsTrue(response.Headers.CacheControl.MustRevalidate); - } - } - } - } - - public class HttpPost : WebApiModuleTest - { - [Test] - public async Task JsonData_ReturnsOk() - { - using (var client = new HttpClient()) - { - var model = new Person { Key = 10, Name = "Test" }; - var payloadJson = new StringContent( - Json.Serialize(model), - System.Text.Encoding.UTF8, - "application/json"); - - var response = await client.PostAsync(WebServerUrl + TestController.GetPath, payloadJson); - - var result = Json.Deserialize(await response.Content.ReadAsStringAsync()); - Assert.IsNotNull(result); - Assert.AreEqual(result.Name, model.Name); - } - } - } - - public class Http405 : WebApiModuleTest - { - [Test] - public async Task ValidPathInvalidMethod_Returns405() - { - using (var client = new HttpClient()) - { - var request = new HttpRequestMessage(HttpMethod.Delete, WebServerUrl + TestController.GetPath); - - var response = await client.SendAsync(request); - - Assert.AreEqual(response.StatusCode, HttpStatusCode.MethodNotAllowed); - } - } - } - - public class FormData : WebApiModuleTest - { - [TestCase("id", "id")] - [TestCase("id[0]", "id[1]")] - public async Task MultipleIndexedValues_ReturnsOk(string label1, string label2) - { - using (var webClient = new HttpClient()) - { - var content = new[] - { - new KeyValuePair("test", "data"), - new KeyValuePair(label1, "1"), - new KeyValuePair(label2, "2"), - }; - - var formContent = new FormUrlEncodedContent(content); - - var result = await webClient.PostAsync(WebServerUrl + TestController.EchoPath, formContent); - Assert.IsNotNull(result); - var data = await result.Content.ReadAsStringAsync(); - var obj = Json.Deserialize(data); - Assert.IsNotNull(obj); - Assert.AreEqual(content.First().Value, obj.test); - Assert.AreEqual(2, obj.id.Count); - Assert.AreEqual(content.Last().Value, obj.id.Last()); - } - } - - [Test] - public async Task TestDictionaryFormData_ReturnsOk() - { - using (var webClient = new HttpClient()) - { - var content = new[] - { - new KeyValuePair("test", "data"), - new KeyValuePair("id", "1"), - }; - - var formContent = new FormUrlEncodedContent(content); - - var result = await webClient.PostAsync(WebServerUrl + TestController.EchoPath, formContent); - Assert.IsNotNull(result); - var data = await result.Content.ReadAsStringAsync(); - var obj = Json.Deserialize>(data); - Assert.AreEqual(2, obj.Keys.Count); - - Assert.AreEqual(content.First().Key, obj.First().Key); - Assert.AreEqual(content.First().Value, obj.First().Value); - } - } - } - - internal class FormDataSample - { - public string test { get; set; } - public List id { get; set; } - } - } - - public class HttpGet : PersonFixtureBase - { - public HttpGet() - : base(ws => ws.WithWebApiController(), Constants.RoutingStrategy.Wildcard, true) - { - } - - [Test] - public async Task GetJsonData_ReturnsOk() - { - var jsonBody = await GetString(TestController.GetPath); - - Assert.IsNotNull(jsonBody, "Json Body is not null"); - Assert.IsNotEmpty(jsonBody, "Json Body is empty"); - - var remoteList = Json.Deserialize>(jsonBody); - - Assert.IsNotNull(remoteList, "Json Object is not null"); - Assert.AreEqual( - remoteList.Count, - PeopleRepository.Database.Count, - "Remote list count equals local list"); - } - - [Test] - public async Task JsonDataWithSelector_ReturnsOk() - { - await ValidatePerson(TestController.GetPath + PeopleRepository.Database.First().Key); - } - - [Test] - public async Task JsonDataWithMiddleUrl_ReturnsOk() - { - var person = PeopleRepository.Database.First(); - await ValidatePerson(TestController.GetMiddlePath.Replace("*", person.Key.ToString())); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/WebServerTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/WebServerTest.cs deleted file mode 100644 index f9175b9a5..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/WebServerTest.cs +++ /dev/null @@ -1,298 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using Modules; - using NUnit.Framework; - using Swan; - using Swan.Formatters; - using System; - using System.IO; - using System.Linq; - using System.Net.Http; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class WebServerTest - { - private const string DefaultPath = "/"; - private const int Port = 88; - private const string Prefix = "http://localhost:9696"; - - private static string[] GetMultiplePrefixes() - => new[] {"http://localhost:9696", "http://localhost:9697", "http://localhost:9698"}; - - [SetUp] - public void Setup() - { - Terminal.Settings.DisplayLoggingMessageType = LogMessageType.None; - } - - public class Constructors : WebServerTest - { - [Test] - public void DefaultConstructor() - { - var instance = new WebServer(); - Assert.IsNotNull(instance.Listener, "It has a HttpListener"); - Assert.IsNotNull(MimeTypes.DefaultMimeTypes, "It has MimeTypes"); - } - - [Test] - public void ConstructorWithPort() - { - var instance = new WebServer(Port); - Assert.IsNotNull(instance.Listener, "It has a HttpListener"); - Assert.IsNotNull(MimeTypes.DefaultMimeTypes, "It has MimeTypes"); - } - - [Test] - public void ConstructorWithSinglePrefix() - { - var instance = new WebServer(Prefix); - Assert.IsNotNull(instance.Listener, "It has a HttpListener"); - Assert.IsNotNull(MimeTypes.DefaultMimeTypes, "It has MimeTypes"); - } - - [Test] - public void ConstructorWithMultiplePrefixes() - { - var instance = new WebServer(GetMultiplePrefixes()); - Assert.IsNotNull(instance.Listener, "It has a HttpListener"); - Assert.AreEqual(instance.Listener.Prefixes.Count, 3); - } - } - - public class TaskCancellation : WebServerTest - { - [Test] - public void WithCancellationRequested_ExitsSuccessfully() - { - var instance = new WebServer("http://localhost:9696"); - - var cts = new CancellationTokenSource(); - var task = instance.RunAsync(cts.Token); - cts.Cancel(); - - task.Wait(); - instance.Dispose(); - - Assert.IsTrue(task.IsCompleted); - } - } - - public class Modules : WebServerTest - { - [Test] - public void RegisterAndUnregister() - { - var instance = new WebServer(); - instance.RegisterModule(new LocalSessionModule()); - - Assert.AreEqual(instance.Modules.Count, 1, "It has one module"); - - instance.UnregisterModule(typeof(LocalSessionModule)); - - Assert.AreEqual(instance.Modules.Count, 0, "It has not modules"); - } - - [Test] - public void AddHandler() - { - var webModule = new TestWebModule(); - webModule.AddHandler(DefaultPath, HttpVerbs.Any, (ctx, ws) => Task.FromResult(false)); - - Assert.AreEqual(webModule.Handlers.Count, 4, "WebModule has four handlers"); - Assert.AreEqual(webModule.Handlers.Last().Path, DefaultPath, "Default Path is correct"); - Assert.AreEqual(webModule.Handlers.Last().Verb, HttpVerbs.Any, "Default Verb is correct"); - } - -#if NETCOREAPP2_2 - [Test] - public async Task Redirect() - { - var url = Resources.GetServerAddress(); - - using (var instance = new WebServer(url)) - { - instance.RegisterModule(new TestWebModule()); - var runTask = instance.RunAsync(); - using (var handler = new HttpClientHandler()) - { - handler.AllowAutoRedirect = false; - using (var client = new HttpClient(handler)) - { - var request = new HttpRequestMessage(HttpMethod.Get, url + TestWebModule.RedirectUrl); - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(System.Net.HttpStatusCode.Redirect, response.StatusCode); - } - } - } - } - } - - [Test] - public async Task AbsoluteRedirect() - { - var url = Resources.GetServerAddress(); - - using (var instance = new WebServer(url, RoutingStrategy.Wildcard)) - { - instance.RegisterModule(new TestWebModule()); - var runTask = instance.RunAsync(); - - using (var handler = new HttpClientHandler()) - { - handler.AllowAutoRedirect = false; - using (var client = new HttpClient(handler)) - { - var request = - new HttpRequestMessage(HttpMethod.Get, url + TestWebModule.RedirectAbsoluteUrl); - - using (var response = await client.SendAsync(request)) - { - Assert.AreEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode); - } - } - } - } - } -#endif - } - - public class General : WebServerTest - { - [Test] - public void WebMap() - { - var map = new Map - { - Path = DefaultPath, - ResponseHandler = (ctx, ws) => Task.FromResult(false), - Verb = HttpVerbs.Any, - }; - - Assert.AreEqual(map.Path, DefaultPath, "Default Path is correct"); - Assert.AreEqual(map.Verb, HttpVerbs.Any, "Default Verb is correct"); - } - - [Test] - public void ExceptionText() - { - Assert.ThrowsAsync(async () => - { - var url = Resources.GetServerAddress(); - - using (var instance = new WebServer(url)) - { - instance.RegisterModule(new FallbackModule((ctx, ct) => - throw new InvalidOperationException("Error"))); - - var runTask = instance.RunAsync(); - var request = new HttpClient(); - await request.GetStringAsync(url); - } - }); - } - - [Test] - public void EmptyModules_NotFoundStatusCode() - { - Assert.ThrowsAsync(async () => - { - var url = Resources.GetServerAddress(); - - using (var instance = new WebServer(url)) - { - var runTask = instance.RunAsync(); - var request = new HttpClient(); - await request.GetStringAsync(url); - } - }); - } - - [TestCase("iso-8859-1")] - [TestCase("utf-8")] - [TestCase("utf-16")] - public async Task EncodingTest(string encodeName) - { - var url = Resources.GetServerAddress(); - - using (var instance = new WebServer(url)) - { - instance.RegisterModule(new FallbackModule((ctx, ct) => - { - var encoding = Encoding.GetEncoding("UTF-8"); - - try - { - var encodeValue = - ctx.Request.ContentType.Split(';') - .FirstOrDefault(x => - x.Trim().StartsWith("charset", StringComparison.OrdinalIgnoreCase)) - ? - .Split('=') - .Skip(1) - .FirstOrDefault()? - .Trim(); - encoding = Encoding.GetEncoding(encodeValue ?? throw new InvalidOperationException()); - } - catch - { - Assert.Inconclusive("Invalid encoding in system"); - } - - return ctx.JsonResponseAsync(new EncodeCheck - { - Encoding = encoding.EncodingName, - IsValid = ctx.Request.ContentEncoding.EncodingName == encoding.EncodingName, - }, - ct); - })); - - var runTask = instance.RunAsync(); - - using (var client = new HttpClient()) - { - client.DefaultRequestHeaders.Accept - .Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); - - var request = new HttpRequestMessage(HttpMethod.Post, url + TestWebModule.RedirectUrl) - { - Content = new StringContent( - "POST DATA", - Encoding.GetEncoding(encodeName), - "application/json"), - }; - - using (var response = await client.SendAsync(request)) - { - var stream = await response.Content.ReadAsStreamAsync(); - using (var ms = new MemoryStream()) - { - stream.CopyTo(ms); - var data = ms.ToArray().ToText(); - - Assert.IsNotNull(data, "Data is not empty"); - var model = Json.Deserialize(data); - - Assert.IsNotNull(model); - Assert.IsTrue(model.IsValid); - } - } - } - } - } - - internal class EncodeCheck - { - public string Encoding { get; set; } - - public bool IsValid { get; set; } - } - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTest.cs deleted file mode 100644 index dd049a509..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTest.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using System.Text; - using Modules; - using NUnit.Framework; - using Swan.Formatters; - using System; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class WebSocketsModuleTest : WebSocketsModuleTestBase - { - public WebSocketsModuleTest() - : base( - RoutingStrategy.Wildcard, - ws => - { - ws.RegisterModule(new WebSocketsModule()); - ws.Module().RegisterWebSocketsServer(); - ws.Module().RegisterWebSocketsServer(); - ws.Module().RegisterWebSocketsServer(); - }, - "test/") - { - // placeholder - } - - [Test] - public async Task TestConnectWebSocket() - { - await ConnectWebSocket(); - } - - [Test] - public async Task TestSendBigDataWebSocket() - { - var webSocketUrl = new Uri($"{WebServerUrl.Replace("http", "ws")}bigdata"); - - var clientSocket = new System.Net.WebSockets.ClientWebSocket(); - await clientSocket.ConnectAsync(webSocketUrl, default); - - var buffer = new ArraySegment(Encoding.UTF8.GetBytes("HOLA")); - await clientSocket.SendAsync(buffer, System.Net.WebSockets.WebSocketMessageType.Text, true, default); - - var json = await ReadString(clientSocket); - Assert.AreEqual(Json.Serialize(BigDataWebSocket.BigDataObject), json); - } - - [Test] - public async Task TestWithDifferentCloseResponse() - { - var webSocketUrl = new Uri($"{WebServerUrl.Replace("http", "ws")}close"); - - var clientSocket = new System.Net.WebSockets.ClientWebSocket(); - await clientSocket.ConnectAsync(webSocketUrl, default); - - var buffer = new ArraySegment(new byte[8192]); - var result = await clientSocket.ReceiveAsync(buffer, default); - - Assert.IsTrue(result.CloseStatus.HasValue); - Assert.IsTrue(result.CloseStatus.Value == System.Net.WebSockets.WebSocketCloseStatus.InvalidPayloadData); - } - } - - [TestFixture] - public class WebSocketsWildcard : WebSocketsModuleTestBase - { - public WebSocketsWildcard() - : base( - RoutingStrategy.Wildcard, - ws => - { - ws.RegisterModule(new WebSocketsModule()); - ws.Module().RegisterWebSocketsServer(); - }, - "test/*") - { - // placeholder - } - - [Test] - public async Task TestConnectWebSocket() - { - await ConnectWebSocket(); - } - } - - [TestFixture] - public class WebSocketsModuleTestRegex : WebSocketsModuleTestBase - { - public WebSocketsModuleTestRegex() - : base( - RoutingStrategy.Regex, - ws => - { - ws.RegisterModule(new WebSocketsModule()); - ws.Module().RegisterWebSocketsServer(); - }, - "test/{100}") - { - // placeholder - } - - [Test] - public async Task TestConnectWebSocket() - { - await ConnectWebSocket(); - } - } -} \ No newline at end of file diff --git a/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTestBase.cs b/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTestBase.cs deleted file mode 100644 index 6be784ded..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/WebSocketsModuleTestBase.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using Constants; - using NUnit.Framework; - using System; - using System.IO; - using System.Text; - using System.Threading.Tasks; - - public abstract class WebSocketsModuleTestBase : FixtureBase - { - private readonly string _url; - - protected WebSocketsModuleTestBase(RoutingStrategy strategy, Action builder, string url) - : base(builder, strategy) - { - _url = url; - } - - protected static async Task ReadString(System.Net.WebSockets.ClientWebSocket ws) - { - var buffer = new ArraySegment(new byte[8192]); - - using (var ms = new MemoryStream()) - { - System.Net.WebSockets.WebSocketReceiveResult result; - - do - { - result = await ws.ReceiveAsync(buffer, default); - ms.Write(buffer.Array, buffer.Offset, result.Count); - } - while (!result.EndOfMessage); - - return Encoding.UTF8.GetString(ms.ToArray()); - } - } - - protected async Task ConnectWebSocket() - { - var websocketUrl = new Uri(WebServerUrl.Replace("http", "ws") + _url); - - var clientSocket = new System.Net.WebSockets.ClientWebSocket(); - await clientSocket.ConnectAsync(websocketUrl, default); - - Assert.AreEqual( - System.Net.WebSockets.WebSocketState.Open, - clientSocket.State, - $"Connection should be open, but the status is {clientSocket.State} - {websocketUrl}"); - - var buffer = new ArraySegment(Encoding.UTF8.GetBytes("HOLA")); - await clientSocket.SendAsync(buffer, System.Net.WebSockets.WebSocketMessageType.Text, true, default); - - Assert.AreEqual(await ReadString(clientSocket), "HELLO"); - } - } -} diff --git a/test/Unosquare.Labs.EmbedIO.Tests/WildcardRoutingTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/WildcardRoutingTest.cs deleted file mode 100644 index 71637b0f4..000000000 --- a/test/Unosquare.Labs.EmbedIO.Tests/WildcardRoutingTest.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Tests -{ - using NUnit.Framework; - using System.Threading.Tasks; - using TestObjects; - - [TestFixture] - public class WildcardRoutingTest : FixtureBase - { - public WildcardRoutingTest() - : base(ws => ws.RegisterModule(new TestRoutingModule()), Constants.RoutingStrategy.Wildcard, true) - { - // placeholder - } - - [Test] - public async Task WithoutWildcard() - { - var call = await GetString("empty"); - - Assert.AreEqual("data", call); - } - - [Test] - public async Task WithWildcard() - { - var call = await GetString("data/1"); - - Assert.AreEqual("1", call); - } - - [Test] - public async Task MultipleWildcard() - { - var call = await GetString("data/1/time"); - - Assert.AreEqual("time", call); - } - } -} \ No newline at end of file diff --git a/toc.yml b/toc.yml index d17395d9b..f7bedb658 100644 --- a/toc.yml +++ b/toc.yml @@ -1,6 +1,8 @@ - name: API Documentation href: obj/api/ +- name: Upgrade from v2 + href: wiki/Upgrade-from-v2.md - name: Cookbook href: wiki/Cookbook.md - name: Self signed certified - href: wiki/Self-signed-certified-(Windows).md \ No newline at end of file + href: wiki/Self-signed-certified-(Windows).md