Skip to content

Commit

Permalink
AzureFunctions
Browse files Browse the repository at this point in the history
  • Loading branch information
hlaueriksson committed Jul 10, 2024
1 parent 25f956b commit 59cd312
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 35 deletions.
45 changes: 41 additions & 4 deletions src/CommandQuery.AzureFunctions/CommandFunction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Net;
using System.Text.Json;
using CommandQuery.SystemTextJson;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

Expand All @@ -10,23 +12,26 @@ namespace CommandQuery.AzureFunctions
public class CommandFunction : ICommandFunction
{
private readonly ICommandProcessor _commandProcessor;
private readonly ILogger<CommandFunction> _logger;
private readonly JsonSerializerOptions? _options;

/// <summary>
/// Initializes a new instance of the <see cref="CommandFunction"/> class.
/// </summary>
/// <param name="commandProcessor">An <see cref="ICommandProcessor"/>.</param>
/// <param name="logger">An <see cref="ILogger{T}"/>.</param>
/// <param name="options"><see cref="JsonSerializerOptions"/> to control the behavior during deserialization of <see cref="HttpRequestData.Body"/> and serialization of <see cref="HttpResponseData.Body"/>.</param>
public CommandFunction(ICommandProcessor commandProcessor, JsonSerializerOptions? options = null)
public CommandFunction(ICommandProcessor commandProcessor, ILogger<CommandFunction> logger, JsonSerializerOptions? options = null)
{
_commandProcessor = commandProcessor;
_logger = logger;
_options = options;
}

/// <inheritdoc />
public async Task<HttpResponseData> HandleAsync(string commandName, HttpRequestData req, ILogger? logger, CancellationToken cancellationToken = default)
public async Task<HttpResponseData> HandleAsync(string commandName, HttpRequestData req, CancellationToken cancellationToken = default)
{
logger?.LogInformation("Handle {Command}", commandName);
_logger.LogInformation("Handle {Command}", commandName);

if (req is null)
{
Expand All @@ -47,12 +52,44 @@ public async Task<HttpResponseData> HandleAsync(string commandName, HttpRequestD
catch (Exception exception)
{
var payload = await req.ReadAsStringAsync().ConfigureAwait(false);
logger?.LogError(exception, "Handle command failed: {Command}, {Payload}", commandName, payload);
_logger.LogError(exception, "Handle command failed: {Command}, {Payload}", commandName, payload);

return exception.IsHandled()
? await req.BadRequestAsync(exception, _options).ConfigureAwait(false)
: await req.InternalServerErrorAsync(exception, _options).ConfigureAwait(false);
}
}

/// <inheritdoc />
public async Task<IActionResult> HandleAsync(string commandName, HttpRequest req, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Handle {Command}", commandName);

if (req is null)
{
throw new ArgumentNullException(nameof(req));
}

try
{
var result = await _commandProcessor.ProcessAsync(commandName, await req.ReadAsStringAsync().ConfigureAwait(false), _options, cancellationToken).ConfigureAwait(false);

if (result == CommandResult.None)
{
return new OkResult();
}

return new OkObjectResult(result.Value);
}
catch (Exception exception)
{
var payload = await req.ReadAsStringAsync().ConfigureAwait(false);
_logger.LogError(exception, "Handle command failed: {Command}, {Payload}", commandName, payload);

return exception.IsHandled()
? new BadRequestObjectResult(exception.ToError())
: new ObjectResult(exception.ToError()) { StatusCode = StatusCodes.Status500InternalServerError };
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.22.0" />
</ItemGroup>

<ItemGroup>
Expand Down
16 changes: 13 additions & 3 deletions src/CommandQuery.AzureFunctions/ICommandFunction.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

namespace CommandQuery.AzureFunctions
{
Expand All @@ -13,10 +14,19 @@ public interface ICommandFunction
/// </summary>
/// <param name="commandName">The name of the command.</param>
/// <param name="req">A <see cref="HttpRequestData"/>.</param>
/// <param name="logger">An <see cref="ILogger"/>.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>The result for status code <c>200</c>, or an error for status code <c>400</c> and <c>500</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="req"/> is <see langword="null"/>.</exception>
Task<HttpResponseData> HandleAsync(string commandName, HttpRequestData req, ILogger? logger, CancellationToken cancellationToken = default);
Task<HttpResponseData> HandleAsync(string commandName, HttpRequestData req, CancellationToken cancellationToken = default);

/// <summary>
/// Handle a command.
/// </summary>
/// <param name="commandName">The name of the command.</param>
/// <param name="req">A <see cref="HttpRequest"/>.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>The result for status code <c>200</c>, or an error for status code <c>400</c> and <c>500</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="req"/> is <see langword="null"/>.</exception>
Task<IActionResult> HandleAsync(string commandName, HttpRequest req, CancellationToken cancellationToken = default);
}
}
16 changes: 13 additions & 3 deletions src/CommandQuery.AzureFunctions/IQueryFunction.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

namespace CommandQuery.AzureFunctions
{
Expand All @@ -13,10 +14,19 @@ public interface IQueryFunction
/// </summary>
/// <param name="queryName">The name of the query.</param>
/// <param name="req">A <see cref="HttpRequestData"/>.</param>
/// <param name="logger">An <see cref="ILogger"/>.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>The result for status code <c>200</c>, or an error for status code <c>400</c> and <c>500</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="req"/> is <see langword="null"/>.</exception>
Task<HttpResponseData> HandleAsync(string queryName, HttpRequestData req, ILogger? logger, CancellationToken cancellationToken = default);
Task<HttpResponseData> HandleAsync(string queryName, HttpRequestData req, CancellationToken cancellationToken = default);

/// <summary>
/// Handle a query.
/// </summary>
/// <param name="queryName">The name of the query.</param>
/// <param name="req">A <see cref="HttpRequest"/>.</param>
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>The result for status code <c>200</c>, or an error for status code <c>400</c> and <c>500</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="req"/> is <see langword="null"/>.</exception>
Task<IActionResult> HandleAsync(string queryName, HttpRequest req, CancellationToken cancellationToken = default);
}
}
26 changes: 26 additions & 0 deletions src/CommandQuery.AzureFunctions/Internal/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text;
using Microsoft.AspNetCore.Http;

namespace CommandQuery.AzureFunctions
{
internal static class HttpRequestExtensions
{
internal static async Task<string?> ReadAsStringAsync(this HttpRequest req, Encoding? encoding = null)
{
if (req is null)
{
throw new ArgumentNullException(nameof(req));
}

if (req.Body is null)
{
return null;
}

using (var reader = new StreamReader(req.Body, bufferSize: 1024, detectEncodingFromByteOrderMarks: true, encoding: encoding ?? Encoding.UTF8, leaveOpen: true))
{
return await reader.ReadToEndAsync().ConfigureAwait(false);
}
}
}
}
53 changes: 49 additions & 4 deletions src/CommandQuery.AzureFunctions/QueryFunction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Text.Json;
using System.Web;
using CommandQuery.SystemTextJson;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;

Expand All @@ -10,23 +13,26 @@ namespace CommandQuery.AzureFunctions
public class QueryFunction : IQueryFunction
{
private readonly IQueryProcessor _queryProcessor;
private readonly ILogger<QueryFunction> _logger;
private readonly JsonSerializerOptions? _options;

/// <summary>
/// Initializes a new instance of the <see cref="QueryFunction"/> class.
/// </summary>
/// <param name="queryProcessor">An <see cref="IQueryProcessor"/>.</param>
/// <param name="logger">An <see cref="ILogger{T}"/>.</param>
/// <param name="options"><see cref="JsonSerializerOptions"/> to control the behavior during deserialization of <see cref="HttpRequestData.Body"/> and serialization of <see cref="HttpResponseData.Body"/>.</param>
public QueryFunction(IQueryProcessor queryProcessor, JsonSerializerOptions? options = null)
public QueryFunction(IQueryProcessor queryProcessor, ILogger<QueryFunction> logger, JsonSerializerOptions? options = null)
{
_queryProcessor = queryProcessor;
_logger = logger;
_options = options;
}

/// <inheritdoc />
public async Task<HttpResponseData> HandleAsync(string queryName, HttpRequestData req, ILogger? logger, CancellationToken cancellationToken = default)
public async Task<HttpResponseData> HandleAsync(string queryName, HttpRequestData req, CancellationToken cancellationToken = default)
{
logger?.LogInformation("Handle {Query}", queryName);
_logger.LogInformation("Handle {Query}", queryName);

if (req is null)
{
Expand All @@ -44,7 +50,7 @@ public async Task<HttpResponseData> HandleAsync(string queryName, HttpRequestDat
catch (Exception exception)
{
var payload = req.Method == "GET" ? req.Url.ToString() : await req.ReadAsStringAsync().ConfigureAwait(false);
logger?.LogError(exception, "Handle query failed: {Query}, {Payload}", queryName, payload);
_logger.LogError(exception, "Handle query failed: {Query}, {Payload}", queryName, payload);

return exception.IsHandled()
? await req.BadRequestAsync(exception, _options).ConfigureAwait(false)
Expand All @@ -58,5 +64,44 @@ Dictionary<string, IEnumerable<string>> Dictionary(Uri url)
return query.AllKeys.ToDictionary<string?, string, IEnumerable<string>>(k => k!, k => query.GetValues(k)!);
}
}

/// <inheritdoc />
public async Task<IActionResult> HandleAsync(string queryName, HttpRequest req, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Handle {Query}", queryName);

if (req is null)
{
throw new ArgumentNullException(nameof(req));
}

try
{
var result = req.Method == "GET"
? await _queryProcessor.ProcessAsync<object>(queryName, Dictionary(req.Query), cancellationToken).ConfigureAwait(false)
: await _queryProcessor.ProcessAsync<object>(queryName, await req.ReadAsStringAsync().ConfigureAwait(false), _options, cancellationToken).ConfigureAwait(false);

return new OkObjectResult(result);
}
catch (Exception exception)
{
var payload = req.Method == "GET" ? req.GetDisplayUrl() : await req.ReadAsStringAsync().ConfigureAwait(false);
_logger.LogError(exception, "Handle query failed: {Query}, {Payload}", queryName, payload);

return exception.IsHandled()
? new BadRequestObjectResult(exception.ToError())
: new ObjectResult(exception.ToError()) { StatusCode = StatusCodes.Status500InternalServerError };
}

Dictionary<string, IEnumerable<string>> Dictionary(IQueryCollection query)
{
if (query == null)
{
return [];
}

return query.Keys.ToDictionary<string?, string, IEnumerable<string>>(k => k!, k => query[k]);
}
}
}
}
16 changes: 6 additions & 10 deletions tests/CommandQuery.AzureFunctions.Tests/CommandFunctionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using LoFuUnit.NUnit;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;

Expand All @@ -26,8 +25,6 @@ public async Task SetUp()
var context = new Mock<FunctionContext>();
Req = new FakeHttpRequestData(context.Object);
await Req.Body.WriteAsync(Encoding.UTF8.GetBytes("{}"));

Logger = Use<ILogger>();
}

[LoFu, Test]
Expand All @@ -39,13 +36,13 @@ public async Task when_handling_the_command()
async Task should_invoke_the_command_processor()
{
Req.Body.Position = 0;
var result = await Subject.HandleAsync(CommandName, Req, Logger);
var result = await Subject.HandleAsync(CommandName, Req);
result.StatusCode.Should().Be(HttpStatusCode.OK);
}

async Task should_throw_when_request_is_null()
{
Func<Task> act = () => Subject.HandleAsync(CommandName, null, Logger);
Func<Task> act = () => Subject.HandleAsync(CommandName, (HttpRequestData)null);
await act.Should().ThrowAsync<ArgumentNullException>();
}

Expand All @@ -54,7 +51,7 @@ async Task should_handle_CommandProcessorException()
Req.Body.Position = 0;
The<Mock<ICommandProcessor>>().Setup(x => x.ProcessAsync(It.IsAny<FakeCommand>(), It.IsAny<CancellationToken>())).Throws(new CommandProcessorException("fail"));

var result = await Subject.HandleAsync(CommandName, Req, Logger);
var result = await Subject.HandleAsync(CommandName, Req);

await result.ShouldBeErrorAsync("fail", HttpStatusCode.BadRequest);
}
Expand All @@ -64,7 +61,7 @@ async Task should_handle_CommandException()
Req.Body.Position = 0;
The<Mock<ICommandProcessor>>().Setup(x => x.ProcessAsync(It.IsAny<FakeCommand>(), It.IsAny<CancellationToken>())).Throws(new CommandException("invalid"));

var result = await Subject.HandleAsync(CommandName, Req, Logger);
var result = await Subject.HandleAsync(CommandName, Req);

await result.ShouldBeErrorAsync("invalid", HttpStatusCode.BadRequest);
}
Expand All @@ -74,7 +71,7 @@ async Task should_handle_Exception()
Req.Body.Position = 0;
The<Mock<ICommandProcessor>>().Setup(x => x.ProcessAsync(It.IsAny<FakeCommand>(), It.IsAny<CancellationToken>())).Throws(new Exception("fail"));

var result = await Subject.HandleAsync(CommandName, Req, Logger);
var result = await Subject.HandleAsync(CommandName, Req);

await result.ShouldBeErrorAsync("fail", HttpStatusCode.InternalServerError);
}
Expand All @@ -92,7 +89,7 @@ async Task should_return_the_result_from_the_command_processor()
var expected = new FakeResult();
The<Mock<ICommandProcessor>>().Setup(x => x.ProcessAsync(It.IsAny<FakeResultCommand>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(expected));

var result = await Subject.HandleAsync(CommandName, Req, Logger);
var result = await Subject.HandleAsync(CommandName, Req);
result.Body.Position = 0;

result.StatusCode.Should().Be(HttpStatusCode.OK);
Expand All @@ -101,7 +98,6 @@ async Task should_return_the_result_from_the_command_processor()
}

HttpRequestData Req;
ILogger Logger;
string CommandName;
}
}
Loading

0 comments on commit 59cd312

Please sign in to comment.