Compare commits

..

No commits in common. "2b5d6b6a544c16d24930d0ded504b1762e58bf5a" and "9082d7a4d054cb048d423d278bc503cd048a748b" have entirely different histories.

12 changed files with 94 additions and 172 deletions

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<!--package info-->
<PackageId>DTLib.Web</PackageId>
<Version>1.1.2</Version>
<Version>1.0.0</Version>
<Authors>Timerix</Authors>
<Description>HTTP Server with simple routing</Description>
<RepositoryType>GIT</RepositoryType>
@ -25,6 +25,6 @@
<ProjectReference Include="..\DTLib\DTLib.csproj" />
</ItemGroup>
<ItemGroup Condition=" '$(Configuration)' != 'Debug' ">
<PackageReference Include="DTLib" Version="1.6.5" />
<PackageReference Include="DTLib" Version="1.6.*" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,6 @@
namespace DTLib.Web.Routes;
public class DelegateRouteHandler(Func<HttpListenerContext, Task<HttpStatusCode>> routeHandler) : RouteHandler
public class DelegateRoute(Func<HttpListenerContext, Task<HttpStatusCode>> routeHandler) : Route
{
public override Task<HttpStatusCode> HandleRequest(HttpListenerContext ctx) => routeHandler(ctx);
}

View File

@ -1,6 +0,0 @@
namespace DTLib.Web.Routes;
public interface IRouter
{
Task<HttpStatusCode> Resolve(HttpListenerContext ctx);
}

View File

@ -1,6 +1,6 @@
namespace DTLib.Web.Routes;
public abstract class RouteHandler
public abstract class Route
{
public abstract Task<HttpStatusCode> HandleRequest(HttpListenerContext ctx);
}

View File

@ -0,0 +1,33 @@
namespace DTLib.Web.Routes;
public class ServeFilesRoute(IOPath _publicDir, string _homePageUrl = "index.html") : Route
{
public override async Task<HttpStatusCode> HandleRequest(HttpListenerContext ctx)
{
if (ctx.Request.HttpMethod != "GET")
return HttpStatusCode.BadRequest;
string requestPath = ctx.Request.Url?.AbsolutePath ?? "/";
if (requestPath == "/")
requestPath = _homePageUrl;
string ext = Path.Extension(requestPath).Str;
IOPath filePath = Path.Concat(_publicDir, requestPath);
if (!File.Exists(filePath))
return HttpStatusCode.NotFound;
string contentType = ext switch
{
"html" => "text/html",
"css" => "text/css",
"js" or "jsx" or "ts" or "tsx" or "map" => "text/javascript",
_ => "binary/octet-stream"
};
ctx.Response.Headers.Set("Content-Type", contentType);
ctx.Response.Headers.Set("Content-Disposition", "attachment filename=" + filePath.LastName());
var fileStream = File.OpenRead(filePath);
ctx.Response.ContentLength64 = fileStream.Length;
await fileStream.CopyToAsync(ctx.Response.OutputStream);
return HttpStatusCode.OK;
}
}

View File

@ -1,40 +0,0 @@
namespace DTLib.Web.Routes;
public class ServeFilesRouteHandler(IOPath _publicDir, string _homePageUrl = "index.html") : RouteHandler
{
public override async Task<HttpStatusCode> HandleRequest(HttpListenerContext ctx)
{
if (ctx.Request.HttpMethod != "GET")
return HttpStatusCode.BadRequest;
string requestPath = ctx.Request.Url?.AbsolutePath!;
if (string.IsNullOrEmpty(requestPath) || requestPath == "/")
{
requestPath = _homePageUrl;
}
string ext = Path.Extension(requestPath).Str;
IOPath filePath = Path.Concat(_publicDir, requestPath);
if (!File.Exists(filePath))
return HttpStatusCode.NotFound;
List<(string key, string val)> headers = ext switch
{
"html" => [("Content-Type", "text/html")],
"css" => [("Content-Type", "text/css")],
"js" or "jsx" or "ts" or "tsx" or "map" => [("Content-Type", "text/javascript")],
_ =>
[
("Content-Type", "binary/octet-stream"),
("Content-Disposition", $"attachment filename={filePath.LastName()}")
]
};
foreach (var header in headers)
ctx.Response.Headers.Set(header.key, header.val);
var fileStream = File.OpenRead(filePath);
ctx.Response.ContentLength64 = fileStream.Length;
await fileStream.CopyToAsync(ctx.Response.OutputStream);
return HttpStatusCode.OK;
}
}

View File

@ -1,48 +0,0 @@
namespace DTLib.Web.Routes;
public class SimpleRouter : IRouter
{
/// route for base url
public RouteHandler? HomePageRoute = null;
/// route for any url that doesn't have its own handler
public RouteHandler? DefaultRoute = null;
private readonly Dictionary<string, RouteHandler> _routes = new();
private readonly ILogger _logger;
public SimpleRouter(ILogger logger)
{
_logger = logger;
}
public void MapRoute(string url, Func<HttpListenerContext, Task<HttpStatusCode>> route)
=> MapRoute(url, new DelegateRouteHandler(route));
public void MapRoute(string url, RouteHandler route) => _routes.Add(url, route);
public async Task<HttpStatusCode> Resolve(HttpListenerContext ctx)
{
string requestPath = ctx.Request.Url?.AbsolutePath ?? "/";
RouteHandler? route;
if(HomePageRoute != null && requestPath == "/")
route = HomePageRoute;
else if (_routes.TryGetValue(requestPath, out var routeDelegate))
route = routeDelegate;
else route = DefaultRoute;
HttpStatusCode status;
if (route == null)
{
_logger.LogWarn(nameof(SimpleRouter), $"couldn't resolve request path {requestPath}");
status = HttpStatusCode.NotFound;
}
else status = await route.HandleRequest(ctx);
ctx.Response.StatusCode = (int)status;
await ctx.Response.OutputStream.FlushAsync();
ctx.Response.OutputStream.Close();
return status;
}
}

View File

@ -3,27 +3,32 @@ global using System.Collections.Generic;
global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using DTLib;
global using DTLib.Demystifier;
global using DTLib.Filesystem;
global using DTLib.Logging;
global using System.Net;
using DTLib.Extensions;
using DTLib.Web.Routes;
namespace DTLib.Web;
public class WebApp
internal class WebApp
{
private readonly string _baseUrl;
private readonly ContextLogger _logger;
private readonly IRouter _router;
private readonly CancellationToken _stopToken;
/// route for base url
public Route? HomePageRoute = null;
/// route for any url that doesn't have its own handler
public Route? DefaultRoute = null;
private ContextLogger _logger;
private string _baseUrl;
private CancellationToken _stopToken;
private Dictionary<string, Route> routes = new();
public WebApp(string baseUrl, ILogger logger, IRouter router, CancellationToken stopToken = default)
public WebApp(ILogger logger, string baseUrl, CancellationToken stopToken)
{
_logger = new ContextLogger(nameof(WebApp), logger);
_baseUrl = baseUrl;
_stopToken = stopToken;
_router = router;
}
public async Task Run()
@ -33,10 +38,10 @@ public class WebApp
server.Prefixes.Add(_baseUrl);
server.Start();
_logger.LogInfo("server started");
long requestId = 1;
long requestId = 0;
while (!_stopToken.IsCancellationRequested)
{
var ctx = await server.GetContextAsync().AsCancellable(_stopToken);
var ctx = await server.GetContextAsync();
HandleRequestAsync(ctx, requestId);
requestId++;
}
@ -53,8 +58,8 @@ public class WebApp
try
{
_logger.LogInfo(logContext, $"[{ctx.Request.HttpMethod}] {ctx.Request.RawUrl} from {ctx.Request.RemoteEndPoint} ...");
var status = await _router.Resolve(ctx);
_logger.LogInfo(logContext, $"{(int)status} ({status})");
var status = await Resolve(ctx);
_logger.LogInfo(logContext, status);
}
catch (Exception ex)
{
@ -62,4 +67,33 @@ public class WebApp
}
}
public void MapRoute(string url, Func<HttpListenerContext, Task<HttpStatusCode>> route)
=> MapRoute(url, new DelegateRoute(route));
public void MapRoute(string url, Route route) => routes.Add(url, route);
public async Task<HttpStatusCode> Resolve(HttpListenerContext ctx)
{
string requestPath = ctx.Request.Url?.AbsolutePath ?? "/";
Route? route;
if(HomePageRoute != null && requestPath == "/")
route = HomePageRoute;
else if (routes.TryGetValue(requestPath, out var routeDelegate))
route = routeDelegate;
else route = DefaultRoute;
HttpStatusCode status;
if (route == null)
{
_logger.LogWarn("couldn't resolve request path {requestPath}");
status = HttpStatusCode.NotFound;
}
else status = await route.HandleRequest(ctx);
ctx.Response.StatusCode = (int)status;
await ctx.Response.OutputStream.FlushAsync(_stopToken);
ctx.Response.OutputStream.Close();
return status;
}
}

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<!--package info-->
<PackageId>DTLib</PackageId>
<Version>1.6.5</Version>
<Version>1.6.3</Version>
<Authors>Timerix</Authors>
<Description>Library for all my C# projects</Description>
<RepositoryType>GIT</RepositoryType>

View File

@ -1,37 +0,0 @@
namespace DTLib.Extensions;
public static class TaskExtensions
{
// https://stackoverflow.com/a/69861689
public static Task<T> AsCancellable<T>(this Task<T> task, CancellationToken token)
{
if (!token.CanBeCanceled)
{
return task;
}
var tcs = new TaskCompletionSource<T>();
// This cancels the returned task:
// 1. If the token has been canceled, it cancels the TCS straightaway
// 2. Otherwise, it attempts to cancel the TCS whenever
// the token indicates cancelled
token.Register(() => tcs.TrySetCanceled(token),
useSynchronizationContext: false);
task.ContinueWith(t =>
{
// Complete the TCS per task status
// If the TCS has been cancelled, this continuation does nothing
if (task.IsCanceled)
tcs.TrySetCanceled();
else if (task.IsFaulted)
tcs.TrySetException(t.Exception!);
else tcs.TrySetResult(t.Result);
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return tcs.Task;
}
}

View File

@ -15,40 +15,29 @@ public readonly struct IOPath
public IOPath(char[] path, bool separatorsFixed=false)
{
if (path.Length == 0)
throw new Exception("path is null or empty");
Str = separatorsFixed ? new string(path) : FixSeparators(path);
}
public IOPath(string path, bool separatorsFixed=false) : this(path.ToCharArray(), separatorsFixed)
public IOPath(string path, bool separatorsFixed=false)
{
if (path.IsNullOrEmpty())
throw new Exception("path is null or empty");
Str = separatorsFixed ? path : FixSeparators(path.ToCharArray());
}
static string FixSeparators(char[] path)
{
int length = path.Length;
char lastChar = path[path.Length - 1];
if (lastChar == Path.Sep || lastChar == Path.NotSep)
if (path[length-1] == Path.Sep || path[length-1] == Path.NotSep)
length--; // removing trailing sep
StringBuilder sb = new StringBuilder();
bool prevWasSeparator = false;
char[] fixed_path = new char[length];
for (int i = 0; i < length; i++)
{
if (path[i] == Path.NotSep)
{
// prevent double separators like this "a//b"
if(!prevWasSeparator)
sb.Append(Path.Sep);
prevWasSeparator = true;
}
else
{
sb.Append(path[i]);
prevWasSeparator = false;
}
fixed_path[i] = Path.Sep;
else fixed_path[i] = path[i];
}
return sb.ToString();
return new string(fixed_path);
}
public static IOPath[] ArrayCast(string[] a, bool separatorsFixed=false)

View File

@ -96,13 +96,10 @@ public static class Path
/// returns just dir name or file name with extension
public static IOPath LastName(this IOPath path)
{
if(path.Length < 2)
return path;
int i = path.LastIndexOf(Sep);
if (i == path.Length - 1) // ends with separator
i = path.LastIndexOf(Sep, i - 1);
if (i == -1)
return path;
if (i == -1) return path;
return path.Substring(i + 1);
}