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