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;
+ }
+}