diff --git a/Karls.Templates.csproj b/Karls.Templates.csproj index 03f6c2e3..c10e504c 100644 --- a/Karls.Templates.csproj +++ b/Karls.Templates.csproj @@ -2,7 +2,7 @@ Template - 1.0.0 + 1.1.0 Karls.Templates Karls Templates Karl-Johan Sjögren diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Class1.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Class1.cs deleted file mode 100644 index 56505559..00000000 --- a/templates/opinionated-solution/src/BASE_NAME.Core/Class1.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace BASE_NAME.Core; - -public class Class1 { -} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Common/HttpClientBase.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Common/HttpClientBase.cs new file mode 100644 index 00000000..c4402a67 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Common/HttpClientBase.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Text.Json; +using BASE_NAME.Core.Exceptions; + +namespace BASE_NAME.Core.Common; + +public abstract class HttpClientBase { + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + + protected HttpClientBase(HttpClient httpClient) { + HttpClient = httpClient; + } + + protected virtual JsonSerializerOptions SerializerOptions => _serializerOptions; + + protected HttpClient HttpClient { get; } + + protected virtual async Task EnsureSuccessStatusCodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) { + if(response.IsSuccessStatusCode) + return; + + var errorMessage = await GetErrorMessageAsync(response, cancellationToken); + + if(response.StatusCode == HttpStatusCode.BadRequest) + throw new HttpBadRequestException(errorMessage, $"400 Bad Request was returned for call to {response.RequestMessage?.RequestUri}."); + + if(response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpUnauthorizedException(errorMessage, $"401 Unauthorized was returned for call to {response.RequestMessage?.RequestUri}."); + + if(response.StatusCode == HttpStatusCode.NotFound) + throw new HttpNotFoundException(errorMessage, $"404 Not Found was returned for call to {response.RequestMessage?.RequestUri}."); + + if(response.StatusCode == HttpStatusCode.InternalServerError) + throw new HttpInternalServerErrorException(errorMessage, $"500 Internal Server Error was returned for call to {response.RequestMessage?.RequestUri}."); + + if(response.StatusCode == HttpStatusCode.ServiceUnavailable) + throw new HttpServiceUnavailableException(errorMessage, $"503 Service Unavailable was returned for call to {response.RequestMessage?.RequestUri}."); + + throw new HttpStatusCodeException(response.StatusCode, errorMessage, $"A non-successful status code was returned for call to {response.RequestMessage?.RequestUri}."); + } + + protected virtual async Task GetErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { + try { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + return content ?? string.Empty; + } catch { + return string.Empty; + } + } + + protected virtual async Task DeserializeResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken); + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpBadRequestException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpBadRequestException.cs new file mode 100644 index 00000000..3d4b1630 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpBadRequestException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpBadRequestException : HttpExceptionBase { + public HttpBadRequestException() : base(HttpStatusCode.BadRequest, null) { + } + + public HttpBadRequestException(string? responseMessage) : base(HttpStatusCode.BadRequest, responseMessage) { + } + + public HttpBadRequestException(string? responseMessage, string message) : base(HttpStatusCode.BadRequest, responseMessage, message) { + } + + public HttpBadRequestException(string? responseMessage, string message, Exception innerException) : base(HttpStatusCode.BadRequest, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpExceptionBase.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpExceptionBase.cs new file mode 100644 index 00000000..d2278df8 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpExceptionBase.cs @@ -0,0 +1,26 @@ +using System.Net; + +[assembly: SuppressMessage("Readability", "RCS1194", Justification = "Exceptions inheriting from HttpExceptionBase needs a HttpStatusCode in their constructors.", Scope = "NamespaceAndDescendants", Target = "~N:BASE_NAME.Core.Exceptions")] + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public abstract class HttpExceptionBase : HttpRequestException { + public new HttpStatusCode? StatusCode { get; } + public string? ResponseMessage { get; } + + protected HttpExceptionBase(HttpStatusCode statusCode, string? responseMessage) { + StatusCode = statusCode; + ResponseMessage = responseMessage; + } + + protected HttpExceptionBase(HttpStatusCode statusCode, string? responseMessage, string message) : base(message) { + StatusCode = statusCode; + ResponseMessage = responseMessage; + } + + protected HttpExceptionBase(HttpStatusCode statusCode, string? responseMessage, string message, Exception innerException) : base(message, innerException) { + StatusCode = statusCode; + ResponseMessage = responseMessage; + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpInternalServerErrorException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpInternalServerErrorException.cs new file mode 100644 index 00000000..ad8ed015 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpInternalServerErrorException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpInternalServerErrorException : HttpExceptionBase { + public HttpInternalServerErrorException() : base(HttpStatusCode.InternalServerError, null) { + } + + public HttpInternalServerErrorException(string? responseMessage) : base(HttpStatusCode.InternalServerError, responseMessage) { + } + + public HttpInternalServerErrorException(string? responseMessage, string message) : base(HttpStatusCode.InternalServerError, responseMessage, message) { + } + + public HttpInternalServerErrorException(string? responseMessage, string message, Exception innerException) : base(HttpStatusCode.InternalServerError, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpNotFoundException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpNotFoundException.cs new file mode 100644 index 00000000..b042f642 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpNotFoundException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpNotFoundException : HttpExceptionBase { + public HttpNotFoundException() : base(HttpStatusCode.NotFound, null) { + } + + public HttpNotFoundException(string? responseMessage) : base(HttpStatusCode.NotFound, responseMessage) { + } + + public HttpNotFoundException(string? responseMessage, string message) : base(HttpStatusCode.NotFound, responseMessage, message) { + } + + public HttpNotFoundException(string? responseMessage, string message, Exception innerException) : base(HttpStatusCode.NotFound, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpServiceUnavailableException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpServiceUnavailableException.cs new file mode 100644 index 00000000..0e87b5f4 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpServiceUnavailableException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpServiceUnavailableException : HttpExceptionBase { + public HttpServiceUnavailableException() : base(HttpStatusCode.ServiceUnavailable, null) { + } + + public HttpServiceUnavailableException(string? responseMessage) : base(HttpStatusCode.ServiceUnavailable, responseMessage) { + } + + public HttpServiceUnavailableException(string? responseMessage, string message) : base(HttpStatusCode.ServiceUnavailable, responseMessage, message) { + } + + public HttpServiceUnavailableException(string? responseMessage, string message, Exception innerException) : base(HttpStatusCode.ServiceUnavailable, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpStatusCodeException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpStatusCodeException.cs new file mode 100644 index 00000000..f0ac58c0 --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpStatusCodeException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpStatusCodeException : HttpExceptionBase { + public HttpStatusCodeException(HttpStatusCode httpStatusCode) : base(httpStatusCode, null) { + } + + public HttpStatusCodeException(HttpStatusCode httpStatusCode, string? responseMessage) : base(httpStatusCode, responseMessage) { + } + + public HttpStatusCodeException(HttpStatusCode httpStatusCode, string? responseMessage, string message) : base(httpStatusCode, responseMessage, message) { + } + + public HttpStatusCodeException(HttpStatusCode httpStatusCode, string? responseMessage, string message, Exception innerException) : base(httpStatusCode, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpUnauthorizedException.cs b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpUnauthorizedException.cs new file mode 100644 index 00000000..92cb8e5e --- /dev/null +++ b/templates/opinionated-solution/src/BASE_NAME.Core/Exceptions/HttpUnauthorizedException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace BASE_NAME.Core.Exceptions; + +[ExcludeFromCodeCoverage] +public class HttpUnauthorizedException : HttpExceptionBase { + public HttpUnauthorizedException() : base(HttpStatusCode.Unauthorized, null) { + } + + public HttpUnauthorizedException(string? responseMessage) : base(HttpStatusCode.Unauthorized, responseMessage) { + } + + public HttpUnauthorizedException(string? responseMessage, string message) : base(HttpStatusCode.Unauthorized, responseMessage, message) { + } + + public HttpUnauthorizedException(string? responseMessage, string message, Exception innerException) : base(HttpStatusCode.Unauthorized, responseMessage, message, innerException) { + } +} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/BASE_NAME.TestHelpers.csproj b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/BASE_NAME.TestHelpers.csproj index af86d420..d2171208 100644 --- a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/BASE_NAME.TestHelpers.csproj +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/BASE_NAME.TestHelpers.csproj @@ -5,4 +5,8 @@ false + + + + diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Class1.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Class1.cs deleted file mode 100644 index 13eeec49..00000000 --- a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Class1.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace BASE_NAME.TestHelpers; - -public class Class1 { -} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpContent.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpContent.cs new file mode 100644 index 00000000..a4124c39 --- /dev/null +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpContent.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Text; + +namespace BASE_NAME.TestHelpers.Http; + +public class FakeHttpContent : HttpContent { + public string Content { get; set; } + + public FakeHttpContent(string content) { + Content = content ?? throw new ArgumentNullException(nameof(content)); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) { + var byteArray = Encoding.UTF8.GetBytes(Content); + await stream.WriteAsync(byteArray); + } + + protected override bool TryComputeLength(out long length) { + length = Encoding.UTF8.GetBytes(Content).LongLength; + return true; + } +} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpMessageHandler.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpMessageHandler.cs new file mode 100644 index 00000000..53104421 --- /dev/null +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FakeHttpMessageHandler.cs @@ -0,0 +1,36 @@ +namespace BASE_NAME.TestHelpers.Http; + +public class FakeHttpMessageHandler : HttpMessageHandler { + private readonly HttpResponseMessage? _response; + private readonly Func? _responseFunc; + private readonly Func>? _asyncResponseFunc; + + public FakeHttpMessageHandler(HttpResponseMessage response) { + _response = response; + } + + public FakeHttpMessageHandler(Func responseFunc) { + _responseFunc = responseFunc; + } + + public FakeHttpMessageHandler(Func> asyncResponseFunc) { + _asyncResponseFunc = asyncResponseFunc; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + if(_response != null) { + return _response; + } + + if(_responseFunc != null) { + return _responseFunc(request, cancellationToken); + } + + if(_asyncResponseFunc != null) { + var response = await _asyncResponseFunc(request, cancellationToken); + return response; + } + + throw new InvalidOperationException("This shouldn't be able to happen."); + } +} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FuncHttpMessageHandler.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FuncHttpMessageHandler.cs new file mode 100644 index 00000000..8e5ad2a2 --- /dev/null +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/FuncHttpMessageHandler.cs @@ -0,0 +1,15 @@ +namespace BASE_NAME.TestHelpers.Http; + +public class FuncHttpMessageHandler : HttpMessageHandler { + private readonly Func _func; + + public FuncHttpMessageHandler(Func func) { + _func = func; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var responseTask = new TaskCompletionSource(); + responseTask.SetResult(_func(request, cancellationToken)); + return responseTask.Task; + } +} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/HttpClientActivator.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/HttpClientActivator.cs new file mode 100644 index 00000000..90f6c9a0 --- /dev/null +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Http/HttpClientActivator.cs @@ -0,0 +1,41 @@ +using System.Net; +using BASE_NAME.Core.Common; + +namespace BASE_NAME.TestHelpers.Http; + +public static class HttpClientActivator where T : HttpClientBase { + public static async Task GetClientWithResourceResponseAsync(HttpStatusCode httpStatusCode, string resourceName, Func createClient) { + var responseJson = await Resources.GetStringAsync(resourceName); + var dummyResponse = new HttpResponseMessage(httpStatusCode) { Content = new FakeHttpContent(responseJson) }; + var client = new HttpClient(new FakeHttpMessageHandler(dummyResponse)) { + BaseAddress = new Uri("http://www.example.com/") + }; + + return createClient(client); + } + + public static T GetClient(HttpStatusCode httpStatusCode, Func createClient) { + var dummyResponse = new HttpResponseMessage(httpStatusCode); + var client = new HttpClient(new FakeHttpMessageHandler(dummyResponse)) { + BaseAddress = new Uri("http://www.example.com/") + }; + + return createClient(client); + } + + public static T GetClient(Func> responseFunc, Func createClient) { + var client = new HttpClient(new FakeHttpMessageHandler(responseFunc)) { + BaseAddress = new Uri("http://www.example.com/") + }; + + return createClient(client); + } + + public static T GetClient(Func responseFunc, Func createClient) { + var client = new HttpClient(new FakeHttpMessageHandler(responseFunc)) { + BaseAddress = new Uri("http://www.example.com/") + }; + + return createClient(client); + } +} diff --git a/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Resources.cs b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Resources.cs new file mode 100644 index 00000000..2f98a892 --- /dev/null +++ b/templates/opinionated-solution/test/BASE_NAME.TestHelpers/Resources.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text; +using Newtonsoft.Json; + +namespace BASE_NAME.TestHelpers; + +public static class Resources { + public static string GetString(string name, Encoding? encoding = null) { + return GetStringAsync(name, encoding).GetAwaiter().GetResult(); + } + + public static async Task GetStringAsync(string name, Encoding? encoding = null) { + var assembly = GetTestAssembly(); + using var resourceStream = assembly!.GetManifestResourceStream($"{assembly.GetName().Name}.Resources.{name}"); + + if(resourceStream == null) + throw new InvalidOperationException($"Failed to load resource stream {assembly.GetName().Name}.Resources." + name); + + using var reader = new StreamReader(resourceStream, encoding ?? Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + public static Stream GetStream(string name) { + return GetStreamAsync(name).GetAwaiter().GetResult(); + } + + public static async Task GetStreamAsync(string name) { + var assembly = GetTestAssembly(); + using var resourceStream = assembly!.GetManifestResourceStream($"{assembly.GetName().Name}.Resources.{name}"); + + if(resourceStream == null) + throw new InvalidOperationException($"Failed to load resource stream {assembly.GetName().Name}.Resources." + name); + + var ms = new MemoryStream(); + await resourceStream.CopyToAsync(ms); + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + public static T? GetTypedFromJson(string name) { + return GetTypedFromJsonAsync(name).GetAwaiter().GetResult(); + } + + public static async Task GetTypedFromJsonAsync(string name) { + var assembly = GetTestAssembly(); + using var resourceStream = assembly!.GetManifestResourceStream($"{assembly.GetName().Name}.Resources.{name}"); + + if(resourceStream == null) { + throw new InvalidOperationException($"Resource with {name} could not be found."); + } + + using var reader = new StreamReader(resourceStream, Encoding.UTF8); + var json = await reader.ReadToEndAsync(); + return JsonConvert.DeserializeObject(json); + } + + internal static string[] GetResourcesNames() { + var assembly = Assembly.GetExecutingAssembly(); + return assembly.GetManifestResourceNames(); + } + + internal static Assembly GetTestAssembly() { + var stackTrace = new StackTrace(); + var frames = stackTrace.GetFrames(); + var assemblies = frames.Select(frame => frame.GetMethod()?.DeclaringType?.Assembly).Distinct(); + + var assembly = assemblies.FirstOrDefault(assembly => { + var assemblyName = assembly?.GetName()?.Name; + + if(string.IsNullOrEmpty(assemblyName)) + return false; + + if(!assemblyName.StartsWith("BASE_NAME.", StringComparison.Ordinal)) + return false; + + if(assemblyName == "BASE_NAME.TestHelpers") + return false; + + return true; + }); + + if(assembly == null) + throw new InvalidOperationException("Failed to find test assembly."); + + return assembly; + } +}