feat: initial development

This commit is contained in:
2023-07-27 01:47:59 +04:00
commit 85dc5981cd
30 changed files with 3075 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
using System.Net;
namespace NaiveHttpServer
{
public delegate bool ParameterProvider(string key, out string value);
public class Context
{
public HttpListenerRequest Request { get; }
public HttpListenerResponse Response { get; }
public ILogger Logger { get; }
public ParameterProvider TryGetParameter { get; set; }
public Context(HttpListenerRequest request, HttpListenerResponse response, ILogger logger)
{
Request = request;
Response = response;
Logger = logger;
TryGetParameter = (string _, out string value) =>
{
value = null!;
return false;
};
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Threading;
namespace NaiveHttpServer
{
internal class DefaultLogger : ILogger
{
public void Error(string message, Exception? exception = null)
{
Write(nameof(Error), message, exception);
}
public void Warning(string message, Exception? exception = null)
{
Write(nameof(Warning), message, exception);
}
public void Info(string message, Exception? exception = null)
{
Write(nameof(Info), message, exception);
}
public void Debug(string message, Exception? exception = null)
{
Write(nameof(Debug), message, exception);
}
private static void Write(string level, string message, Exception? exception)
{
Thread thread = Thread.CurrentThread;
string threadName = string.IsNullOrEmpty(thread.Name) ? thread.ManagedThreadId.ToString() : thread.Name!;
string exceptionString = exception is null ? string.Empty : $"{Environment.NewLine}{exception.Message}{Environment.NewLine}{exception.StackTrace}{Environment.NewLine}";
System.Diagnostics.Debug.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {level.ToUpperInvariant()} [{threadName}] {message}{exceptionString}");
}
}
}

View File

@@ -0,0 +1,9 @@
namespace NaiveHttpServer
{
public static class ErrorCodes
{
public const string Unknown = "UNKNOWN";
public const string NotFoundApi = "NOT_FOUND_API";
public const string NotFoundFile = "NOT_FOUND_FILE";
}
}

View File

@@ -0,0 +1,83 @@
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace NaiveHttpServer
{
public static class Extensions
{
public static readonly JsonSerializerSettings JsonSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore,
};
public static async Task Json(this HttpListenerResponse response, object value)
{
string jsonText = value.ToJson();
byte[] bytes = Encoding.UTF8.GetBytes(jsonText);
response.ContentEncoding = Encoding.UTF8;
response.ContentType = "application/json";
response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes, 0, bytes.Length);
}
public static async Task File(this HttpListenerResponse response, string filePath)
{
if (!System.IO.File.Exists(filePath))
{
return;
}
await FileHelper.ReadAsync(filePath, async stream =>
{
response.ContentType = MimeTypes.GetMimeType(filePath);
response.ContentLength64 = stream.Length;
await stream.CopyToAsync(response.OutputStream);
});
}
public static async Task Html(this HttpListenerResponse response, string template)
{
byte[] buffer = Encoding.UTF8.GetBytes(template);
response.ContentType = System.Net.Mime.MediaTypeNames.Text.Html;
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
}
public static async Task Error(this HttpListenerResponse response, string errorCode, string message, int statusCode = 500)
{
response.StatusCode = statusCode;
await response.Json(new
{
errorCode,
message,
});
}
public static async Task<T?> JsonFromBody<T>(this HttpListenerRequest request)
{
string jsonText = await request.TextFromBody();
return jsonText.ToObject<T>();
}
public static async Task<string> TextFromBody(this HttpListenerRequest request)
{
using StreamReader reader = new(request.InputStream);
return await reader.ReadToEndAsync();
}
public static string ToJson(this object value, Formatting formatting = Formatting.None)
{
return JsonConvert.SerializeObject(value, formatting, JsonSettings);
}
public static T? ToObject<T>(this string json)
{
return JsonConvert.DeserializeObject<T>(json, JsonSettings);
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace NaiveHttpServer
{
public static class FileHelper
{
public static ILogger? Logger { get; set; }
public static async Task WriteAsync(string path, Func<Stream, Task> writer, bool isBackup = true)
{
string tempFilePath = $"{path}.writing";
using (var stream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
0x10000,
FileOptions.SequentialScan)
)
{
await writer(stream);
}
if (File.Exists(path))
{
await SpinRetry(() => File.Replace(tempFilePath, path, isBackup ? BackupPath(path) : null, true));
}
else
{
await SpinRetry(() => File.Move(tempFilePath, path));
}
}
public static async Task ReadAsync(string path, Func<Stream, Task> reader)
{
try
{
await CriticalReadAsync(path, reader);
}
catch (Exception e)
{
string backupPath = BackupPath(path);
if (!File.Exists(backupPath))
{
throw;
}
Logger?.Warning($"Can not read {path}, turn back to backup.", e);
await CriticalReadAsync(backupPath, reader);
}
}
private static async Task CriticalReadAsync(string path, Func<Stream, Task> reader)
{
using FileStream stream = new(
path,
FileMode.OpenOrCreate,
FileAccess.Read,
FileShare.ReadWrite,
0x10000,
FileOptions.SequentialScan);
await reader(stream);
}
private static string BackupPath(string path) => $"{path}.backup";
private static async Task SpinRetry(Action action, int retryCount = 10)
{
for (int i = 0; i < retryCount; i++)
{
try
{
action();
await Task.Delay(100);
break;
}
catch
{
if (i == retryCount - 1)
{
throw;
}
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
namespace NaiveHttpServer
{
public static class HttpMethods
{
public const string Get = "GET";
public const string Post = "POST";
public const string Put = "PUT";
public const string Delete = "DELETE";
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace NaiveHttpServer
{
public interface ILogger
{
void Error(string message, Exception? exception = null);
void Warning(string message, Exception? exception = null);
void Info(string message, Exception? exception = null);
void Debug(string message, Exception? exception = null);
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading.Tasks;
namespace NaiveHttpServer
{
public interface IRouterBuilder
{
IRouterBuilder Get(string url, Func<Context, Task> handler);
IRouterBuilder Post(string url, Func<Context, Task> handler);
IRouterBuilder Delete(string url, Func<Context, Task> handler);
IRouterBuilder Put(string url, Func<Context, Task> handler);
Middleware<Context> Build();
}
}

View File

@@ -0,0 +1,134 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using System.Web;
namespace NaiveHttpServer
{
public delegate Task Middleware<in T>(T ctx, Func<Task> next);
public static class Middlewares
{
public static Middleware<T> Empty<T>() => (_, next) => next();
public static async Task Log(Context ctx, Func<Task> next)
{
ILogger logger = ctx.Logger;
HttpListenerRequest request = ctx.Request;
HttpListenerResponse response = ctx.Response;
logger.Debug($"[In] {request.HttpMethod} {request.RawUrl}");
await next();
logger.Debug($"[Out] {request.HttpMethod} [{response.StatusCode}] {request.RawUrl}");
}
public static async Task ExceptionHandling(Context ctx, Func<Task> next)
{
using HttpListenerResponse response = ctx.Response;
try
{
await next();
}
catch (Exception e)
{
ctx.Logger.Warning("Unexpected exception occurred.", e);
await response.Error(ErrorCodes.Unknown, e.Message);
response.StatusCode = e switch
{
FileNotFoundException => 404,
DirectoryNotFoundException => 404,
UnauthorizedAccessException => 403,
_ => 500,
};
}
}
public static Middleware<Context> NotFound(string documentUrl)
{
return async (ctx, _) =>
{
ctx.Response.StatusCode = 404;
await ctx.Response.Error(
ErrorCodes.NotFoundApi,
$"Not found this api: '{ctx.Request.RawUrl}', and please read the API document: {documentUrl}.");
};
}
public static Middleware<Context> StaticFile(string route, string rootDir)
{
return async (ctx, next) =>
{
// Don't use Request.RawUrl, because it contains url parameters. (e.g. '?a=1&b=2')
string relativePath = ctx.Request.Url.AbsolutePath.TrimStart('/');
bool handled = relativePath.StartsWith(route);
if (!handled)
{
await next();
return;
}
string requestPath = HttpUtility.UrlDecode(relativePath)
.Substring(route.Length)
.ToLowerInvariant()
.TrimStart('/', '\\');
string filePath = Path.Combine(rootDir, requestPath);
switch (ctx.Request.HttpMethod)
{
case HttpMethods.Get:
await ReadLocalFile(filePath, ctx.Response, ctx.Logger);
break;
case HttpMethods.Put:
await WriteLocalFile(filePath, ctx.Request);
ctx.Response.StatusCode = 204;
break;
}
};
}
private static async Task ReadLocalFile(string filePath, HttpListenerResponse response, ILogger logger)
{
if (File.Exists(filePath))
{
await response.File(filePath);
}
else if (Directory.Exists(filePath))
{
string[] filePaths = Directory
.GetFileSystemEntries(filePath)
.Select(Path.GetFileName)
.ToArray();
await response.Json(filePaths);
}
else
{
string message = $"Not found the file: '{filePath}'.";
logger.Warning(message);
await response.Error(ErrorCodes.NotFoundFile, message, 404);
}
}
private static Task WriteLocalFile(string filePath, HttpListenerRequest request)
{
return FileHelper.WriteAsync(filePath, stream => request.InputStream.CopyToAsync(stream));
}
public static Middleware<T> Then<T>(this Middleware<T> middleware, Middleware<T> nextMiddleware)
{
return (ctx, next) => middleware(ctx, () => nextMiddleware(ctx, next));
}
public static Task Run<T>(this Middleware<T> middleware, T ctx)
{
return middleware(ctx, () =>
#if NET45
Task.FromResult(0)
#else
Task.CompletedTask
#endif
);
}
}
}

1032
NaiveHttpServer/MimeTypes.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Net" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<# var mediaTypes = GetMediaTypeList(); #>
// <auto-generated />
namespace <#=System.Runtime.Remoting.Messaging.CallContext.LogicalGetData("NamespaceHint").ToString()#>
{
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
/// <summary>
/// Provides utilities for mapping file names and extensions to MIME-types.
/// </summary>
[CompilerGenerated]
[DebuggerNonUserCode]
public static class MimeTypes
{
/// <summary>
/// The fallback MIME-type. Defaults to <c>application/octet-stream</c>.
/// </summary>
public static string FallbackMimeType { get; set; }
private static readonly Dictionary<string, string> TypeMap;
static MimeTypes()
{
FallbackMimeType = "application/octet-stream";
TypeMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
<# foreach (var mediaType in mediaTypes) { #>
{ "<#= mediaType.Item1 #>", "<#= mediaType.Item2 #>" },
<# } #>
};
}
/// <summary>
/// Gets the MIME-type for the given file name,
/// or <see cref="FallbackMimeType"/> if a mapping doesn't exist.
/// </summary>
/// <param name="fileName">The name of the file.</param>
/// <returns>The MIME-type for the given file name.</returns>
public static string GetMimeType(string fileName)
{
var dotIndex = fileName.LastIndexOf('.');
return dotIndex != -1 &&
fileName.Length > dotIndex + 1 &&
TypeMap.TryGetValue(fileName.Substring(dotIndex + 1), out var result)
? result
: FallbackMimeType;
}
}
}
<#+
private static IList<Tuple<string, string>> GetMediaTypeList()
{
using (var client = new WebClient())
{
var list = client.DownloadString(new Uri("http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types"));
var lines = SplitString(list, '\r', '\n');
return GetMediaTypes(lines).ToList();
}
}
private static IEnumerable<Tuple<string, string>> GetMediaTypes(IEnumerable<string> lines)
{
return lines.Where(x => !x.StartsWith("#"))
.Select(line => SplitString(line, '\t', ' '))
.SelectMany(CreateMediaTypes)
.GroupBy(x => x.Item1)
.Where(x => x.Count() == 1)
.Select(x => x.Single())
.OrderBy(x => x.Item1);
}
private static string[] SplitString(string line, params char[] separator)
{
return line.Split(separator, StringSplitOptions.RemoveEmptyEntries);
}
private static IEnumerable<Tuple<string, string>> CreateMediaTypes(string[] parts)
{
return parts.Skip(1).Select(extension => Tuple.Create(extension, parts.First()));
}
#>

View File

@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net462</TargetFrameworks>
<Version>1.0.0</Version>
<LangVersion>latest</LangVersion>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild Condition="'$(Configuration)'=='Release'">true</GeneratePackageOnBuild>
<Authors>Dingping Zhang</Authors>
<Copyright>Copyright (c) 2020-2021 Dingping Zhang</Copyright>
<PackageProjectUrl>https://github.com/DingpingZhang/NaiveHttpServer</PackageProjectUrl>
<RepositoryUrl>https://github.com/DingpingZhang/NaiveHttpServer</RepositoryUrl>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>A simple C# http server based on the HttpListener.</Description>
<PackageTags>http;http-server;file-server;simple-http-server</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="[13.0.1,)" />
<PackageReference Condition="'$(TargetFramework)' == 'net462'" Include="System.ValueTuple" Version="[4.5.0,)" />
</ItemGroup>
<ItemGroup>
<Reference Condition="'$(TargetFramework)' == 'net462'" Include="System.Web" />
</ItemGroup>
<ItemGroup>
<None Update="MimeTypes.tt">
<Generator>TextTemplatingFileGenerator</Generator>
<LastGenOutput>MimeTypes.cs</LastGenOutput>
</None>
</ItemGroup>
<ItemGroup>
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup>
<ItemGroup>
<Compile Update="MimeTypes.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>MimeTypes.tt</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
namespace NaiveHttpServer
{
public static class NetAclChecker
{
public static ILogger? Logger { get; set; }
public static void AddAddress(string address)
{
AddAddress(address, Environment.UserDomainName, Environment.UserName);
}
public static void AddAddress(string address, string domain, string user)
{
string args = $@"http add urlacl url={address}, user={domain}\{user}";
try
{
ProcessStartInfo processStartInfo = new("netsh", args)
{
Verb = "runas",
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = true,
};
var process = Process.Start(processStartInfo);
process?.WaitForExit();
}
catch (Win32Exception e)
{
if (e.NativeErrorCode == 1223)
{
Logger?.Info("User canceled the operation by rejected the UAC.");
}
else
{
Logger?.Warning($"Failed to 'netsh http add urlacl {address}' with an {nameof(Win32Exception)}.", e);
}
}
catch (Exception e)
{
Logger?.Warning($"Failed to 'netsh http add urlacl {address}'.", e);
}
}
}
}

View File

@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
namespace NaiveHttpServer
{
public class RouterBuilder : IRouterBuilder
{
private static readonly Regex PathParameterRegex = new("(?<=/):(.+?)(?:(?=/)|$)", RegexOptions.Compiled);
private static readonly char[] Separator = { '/' };
private readonly List<(string url, Func<Context, Task> handler)> _getRoutes = new();
private readonly List<(string url, Func<Context, Task> handler)> _postRoutes = new();
private readonly List<(string url, Func<Context, Task> handler)> _deleteRoutes = new();
private readonly List<(string url, Func<Context, Task> handler)> _putRoutes = new();
public IRouterBuilder Get(string url, Func<Context, Task> handler)
{
_getRoutes.Add((url, handler));
return this;
}
public IRouterBuilder Post(string url, Func<Context, Task> handler)
{
_postRoutes.Add((url, handler));
return this;
}
public IRouterBuilder Delete(string url, Func<Context, Task> handler)
{
_deleteRoutes.Add((url, handler));
return this;
}
public IRouterBuilder Put(string url, Func<Context, Task> handler)
{
_putRoutes.Add((url, handler));
return this;
}
public Middleware<Context> Build()
{
var getRoutes = GenerateRegexRoutes(_getRoutes);
var postRoutes = GenerateRegexRoutes(_postRoutes);
var deleteRoutes = GenerateRegexRoutes(_deleteRoutes);
var putRoutes = GenerateRegexRoutes(_putRoutes);
return async (ctx, next) =>
{
bool handled = ctx.Request.HttpMethod.ToUpperInvariant() switch
{
HttpMethods.Get => await TryMatch(getRoutes, ctx),
HttpMethods.Post => await TryMatch(postRoutes, ctx),
HttpMethods.Delete => await TryMatch(deleteRoutes, ctx),
HttpMethods.Put => await TryMatch(putRoutes, ctx),
_ => false,
};
if (!handled)
{
await next();
}
};
}
private static IReadOnlyList<(Regex regex, Func<Context, Task> handler)> GenerateRegexRoutes(IEnumerable<(string url, Func<Context, Task> handler)> routes)
{
var toSortRoutes = routes
.Select(item => (
fragmentCount: item.url.Split(Separator, StringSplitOptions.RemoveEmptyEntries).Length,
item.url,
item.handler))
.ToList();
toSortRoutes.Sort((x, y) => y.fragmentCount - x.fragmentCount);
return toSortRoutes
.Select(item => (regex: GetPathRegex(item.url), item.handler))
.ToList();
}
private static Regex GetPathRegex(string url)
{
HashSet<string> parameterNames = new();
string urlRegex = PathParameterRegex
.Replace(url.Trim('/'), match =>
{
if (!parameterNames.Add(match.Value))
{
throw new ArgumentException($"Cannot contains duplicate variable name: '{match.Value}'.", nameof(url));
}
return $"(?<{match.Groups[1]}>.+?)";
});
return new Regex($"{urlRegex}$", RegexOptions.Compiled);
}
private static async Task<bool> TryMatch(IEnumerable<(Regex regex, Func<Context, Task> handler)> routes, Context ctx)
{
string requestPath = ctx.Request.Url.LocalPath.ToLowerInvariant();
foreach ((Regex regex, Func<Context, Task> handler) in routes)
{
Match match = regex.Match(requestPath);
if (match.Success)
{
NameValueCollection query = HttpUtility.ParseQueryString(ctx.Request.Url.Query, Encoding.UTF8);
ctx.TryGetParameter = (string key, out string value) =>
{
Group group = match.Groups[key];
value = HttpUtility.UrlDecode(group.Value);
if (!group.Success)
{
value = query.Get(key);
}
return !string.IsNullOrEmpty(value);
};
await handler(ctx);
return true;
}
}
return false;
}
}
}

114
NaiveHttpServer/Server.cs Normal file
View File

@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
namespace NaiveHttpServer
{
public class Server
{
private ILogger _logger = new DefaultLogger();
private HttpListener? _listener;
private Middleware<Context> _middleware = Middlewares.Empty<Context>();
public bool IsRunning => _listener is { IsListening: true };
public string HostUrl { get; }
public Server(string host, int port)
{
HostUrl = $"http://{host}:{port}/";
}
public Server Use(ILogger logger)
{
_logger = logger;
return this;
}
public Server Use(Middleware<Context> middleware)
{
_middleware = _middleware.Then(middleware);
return this;
}
public bool Start()
{
try
{
StartHttpListener();
return true;
}
catch (HttpListenerException e)
{
_logger.Warning("Failed to start HttpListener.", e);
if (e.ErrorCode == 5)
{
NetAclChecker.AddAddress(HostUrl);
StartHttpListener();
return true;
}
return false;
}
}
public void Stop()
{
if (_listener is { IsListening: true })
{
_listener.Stop();
_logger.Info("Http server has been stopped.");
}
}
private void StartHttpListener()
{
_listener = new HttpListener();
_listener.Prefixes.Add(HostUrl);
_listener.Start();
AsyncProcessRequest();
_logger.Info($"Http server has started listening: {HostUrl}...");
}
private void AsyncProcessRequest()
{
Task.Run(async () =>
{
while (_listener!.IsListening)
{
try
{
HttpListenerContext context = await _listener.GetContextAsync();
Context ctx = new(context.Request, context.Response, _logger);
#pragma warning disable 4014
_middleware.Run(ctx);
#pragma warning restore 4014
}
catch (IOException e)
{
_logger.Warning(nameof(IOException), e);
}
catch (HttpListenerException e)
{
const int errorOperationAborted = 995;
if (e.ErrorCode == errorOperationAborted)
{
// The IO operation has been aborted because of either a thread exit or an application request.
break;
}
_logger.Warning(nameof(HttpListenerException), e);
}
catch (InvalidOperationException e)
{
_logger.Warning(nameof(InvalidOperationException), e);
}
}
});
}
}
}