Skip to content

Commit

Permalink
Merge pull request #322 from ServiceComposer/set-action-result
Browse files Browse the repository at this point in the history
MVC action results support
  • Loading branch information
mauroservienti authored Oct 8, 2021
2 parents 35fac1c + c21e5ee commit d0178f8
Show file tree
Hide file tree
Showing 11 changed files with 315 additions and 42 deletions.
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ ServiceComposer can be added to existing or new ASP.NET Core projects, or it can

When handling composition requests it possible to leverage the power of ASP.Net Model Binding to bind incoming forms, bodies, query string parameters, or route data to strongly typed C# models. For more information on model binding refer to the [Model Binding](model-binding.md) section.

### ASP.Net MVC Action results

MVC Action results support allow composition handlers to set custom response results for specific scenarios, like for example, handling bad requests or validation error thoat would nornmally require throwing an exception. For more information on action results refer to the [MVC Action results](action-results.md) section.

## Authentication and Authorization

By virtue of leveraging ASP.NET Core 3.x Endpoints ServiceComposer automatically supports authentication and authorization metadata attributes to express authentication and authorization requirements on routes. For more information refer to the [Authentication and Authorization](authentication-authorization.md) section
Expand Down
4 changes: 4 additions & 0 deletions docs/README.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ ServiceComposer can be added to existing or new ASP.NET Core projects, or it can

When handling composition requests it possible to leverage the power of ASP.Net Model Binding to bind incoming forms, bodies, query string parameters, or route data to strongly typed C# models. For more information on model binding refer to the [Model Binding](model-binding.source.md) section.

### ASP.Net MVC Action results

MVC Action results support allow composition handlers to set custom response results for specific scenarios, like for example, handling bad requests or validation error thoat would nornmally require throwing an exception. For more information on action results refer to the [MVC Action results](action-results.source.md) section.

## Authentication and Authorization

By virtue of leveraging ASP.NET Core 3.x Endpoints ServiceComposer automatically supports authentication and authorization metadata attributes to express authentication and authorization requirements on routes. For more information refer to the [Authentication and Authorization](authentication-authorization.source.md) section
Expand Down
52 changes: 52 additions & 0 deletions docs/action-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!--
GENERATED FILE - DO NOT EDIT
This file was generated by [MarkdownSnippets](https://github.com/SimonCropp/MarkdownSnippets).
Source File: /docs/action-results.source.md
To change this file edit the source file and then run MarkdownSnippets.
-->

# ASP.Net MVC Action results

MVC Action results support allow composition handlers to set custom response results for specific scenarios, like for example, handling bad requests or validation error thoat would nornmally require throwing an exception. Setting a custom action result is done by using the `SetActionResult()` `HttpRequest` extension method:

<!-- snippet: net-core-3x-action-results -->
<a id='snippet-net-core-3x-action-results'></a>
```cs
public class UseSetActionResultHandler : ICompositionRequestsHandler
{
[HttpGet("/product/{id}")]
public Task Handle(HttpRequest request)
{
var id = request.RouteValues["id"];

//validate the id format
var problems = new ValidationProblemDetails(new Dictionary<string, string[]>()
{
{ "Id", new []{ "The supplied id does not respect the identifier format." } }
});
var result = new BadRequestObjectResult(problems);

request.SetActionResult(result);

return Task.CompletedTask;
}
}
```
<sup><a href='/src/Snippets.NetCore3x/ActionResult/UseSetActionResultHandler.cs#L10-L31' title='Snippet source file'>snippet source</a> | <a href='#snippet-net-core-3x-action-results' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Using MVC action results require enabling output formatters support:

<!-- snippet: net-core-3x-action-results-required-config -->
<a id='snippet-net-core-3x-action-results-required-config'></a>
```cs
services.AddViewModelComposition(options =>
{
options.ResponseSerialization.UseOutputFormatters = true;
});
```
<sup><a href='/src/Snippets.NetCore3x/ActionResult/UseSetActionResultHandler.cs#L37-L42' title='Snippet source file'>snippet source</a> | <a href='#snippet-net-core-3x-action-results-required-config' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Note: ServiceComposer supports only one action result per request. If two or more composition handlers try to set action results, only the frst one will succeed and subsequent requests will be ignored.
11 changes: 11 additions & 0 deletions docs/action-results.source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# ASP.Net MVC Action results

MVC Action results support allow composition handlers to set custom response results for specific scenarios, like for example, handling bad requests or validation error thoat would nornmally require throwing an exception. Setting a custom action result is done by using the `SetActionResult()` `HttpRequest` extension method:

snippet: net-core-3x-action-results

Using MVC action results require enabling output formatters support:

snippet: net-core-3x-action-results-required-config

Note: ServiceComposer supports only one action result per request. If two or more composition handlers try to set action results, only the frst one will succeed and subsequent requests will be ignored.
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
using ServiceComposer.AspNetCore.Testing;
using Xunit;

namespace ServiceComposer.AspNetCore.Endpoints.Tests
{
public class When_setting_action_result
{
const string expectedError = "I'm not sure I like the Id property value";

class TestGetIntegerHandler : ICompositionRequestsHandler
{
class Model
{
[FromRoute]public int id { get; set; }
}

[HttpGet("/sample/{id}")]
public async Task Handle(HttpRequest request)
{
var model = await request.Bind<Model>();

var problems = new ValidationProblemDetails(new Dictionary<string, string[]>()
{
{ "Id", new []{ expectedError } }
});
var result = new BadRequestObjectResult(problems);

request.SetActionResult(result);
}
}

class TestGetStringHandler : ICompositionRequestsHandler
{
[HttpGet("/sample/{id}")]
public Task Handle(HttpRequest request)
{
var vm = request.GetComposedResponseModel();
vm.AString = "sample";
return Task.CompletedTask;
}
}

[Fact]
public async Task Returns_expected_bad_request_using_output_formatters()
{
// Arrange
var client = new SelfContainedWebApplicationFactoryWithWebHost<Dummy>
(
configureServices: services =>
{
services.AddViewModelComposition(options =>
{
options.AssemblyScanner.Disable();
options.RegisterCompositionHandler<TestGetStringHandler>();
options.RegisterCompositionHandler<TestGetIntegerHandler>();
options.ResponseSerialization.UseOutputFormatters = true;
});
services.AddRouting();
services.AddControllers()
.AddNewtonsoftJson();
},
configure: app =>
{
app.UseRouting();
app.UseEndpoints(builder => builder.MapCompositionHandlers());
}
).CreateClient();

// Act
var response = await client.GetAsync("/sample/1");

// Assert
Assert.False(response.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

var responseString = await response.Content.ReadAsStringAsync();
dynamic responseObj = JObject.Parse(responseString);

dynamic errors = responseObj.errors;
var idErrors = (JArray)errors["Id"];

var error = idErrors[0].Value<string>();

Assert.Equal(expectedError, error);
}

[Fact]
public async Task Throws_if_output_formatters_are_not_enabled()
{
await Assert.ThrowsAsync<NotSupportedException>(async () =>
{
// Arrange
var client = new SelfContainedWebApplicationFactoryWithWebHost<Dummy>
(
configureServices: services =>
{
services.AddViewModelComposition(options =>
{
options.AssemblyScanner.Disable();
options.RegisterCompositionHandler<TestGetStringHandler>();
options.RegisterCompositionHandler<TestGetIntegerHandler>();
options.ResponseSerialization.UseOutputFormatters = false;
});
services.AddRouting();
services.AddControllers()
.AddNewtonsoftJson();
},
configure: app =>
{
app.UseRouting();
app.UseEndpoints(builder => builder.MapCompositionHandlers());
}
).CreateClient();
// Act
var response = await client.GetAsync("/sample/1");
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ namespace ServiceComposer.AspNetCore
public static T GetComposedResponseModel<T>(this Microsoft.AspNetCore.Http.HttpRequest request)
where T : class { }
public static ServiceComposer.AspNetCore.ICompositionContext GetCompositionContext(this Microsoft.AspNetCore.Http.HttpRequest request) { }
public static void SetActionResult(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.Mvc.ActionResult actionResult) { }
}
public static class HttpRequestModelBinderExtension
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ namespace ServiceComposer.AspNetCore
public static T GetComposedResponseModel<T>(this Microsoft.AspNetCore.Http.HttpRequest request)
where T : class { }
public static ServiceComposer.AspNetCore.ICompositionContext GetCompositionContext(this Microsoft.AspNetCore.Http.HttpRequest request) { }
public static void SetActionResult(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.Mvc.ActionResult actionResult) { }
}
public static class HttpRequestModelBinderExtension
{
Expand Down
53 changes: 13 additions & 40 deletions src/ServiceComposer.AspNetCore/CompositionEndpointBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,55 +48,28 @@ public CompositionEndpointBuilder(RoutePattern routePattern, Type[] componentsTy
var viewModel = await CompositionHandler.HandleComposableRequest(context, componentsTypes);
if (viewModel != null)
{
if (useOutputFormatters)
var containsActionResult = context.Items.ContainsKey(HttpRequestExtensions.ComposedActionResultKey);
if(!useOutputFormatters && containsActionResult)
{
OutputFormatterSelector formatterSelector;
IHttpResponseStreamWriterFactory writerFactory;
IOptions<MvcOptions> options;
try
{
formatterSelector = context.RequestServices.GetRequiredService<OutputFormatterSelector>();
writerFactory = context.RequestServices.GetRequiredService<IHttpResponseStreamWriterFactory>();
options = context.RequestServices.GetRequiredService<IOptions<MvcOptions>>();
}
catch (InvalidOperationException e)
{
throw new InvalidOperationException("Unable to resolve one of the services required to support output formatting. " +
"Make sure the application is configured to use MVC services by calling either " +
$"services.{nameof(MvcServiceCollectionExtensions.AddControllers)}(), or " +
$"services.{nameof(MvcServiceCollectionExtensions.AddControllersWithViews)}(), or " +
$"services.{nameof(MvcServiceCollectionExtensions.AddMvc)}(), or " +
$"services.{nameof(MvcServiceCollectionExtensions.AddRazorPages)}().", e);
}
var outputFormatterWriteContext = new OutputFormatterWriteContext(context, writerFactory.CreateWriter, viewModel.GetType(), viewModel);
throw new NotSupportedException($"Setting an action results requires output formatters supports. " +
$"Enable output formatters by setting to true the {nameof(ResponseSerializationOptions.UseOutputFormatters)} " +
$"configuration property in the {nameof(ResponseSerializationOptions)} options.");
}
if (!context.Request.Headers.TryGetValue(HeaderNames.Accept, out var accept))
if (useOutputFormatters)
{
if (containsActionResult)
{
accept = MediaTypeNames.Application.Json;
await context.ExecuteResultAsync(context.Items[HttpRequestExtensions.ComposedActionResultKey] as IActionResult);
}
var mediaTypes = new MediaTypeCollection
{
accept
};
//TODO: log list of configured formatters
var selectedFormatter = formatterSelector.SelectFormatter(
outputFormatterWriteContext,
options.Value.OutputFormatters, mediaTypes);
if (selectedFormatter == null)
else
{
//TODO: log
context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
return;
await context.WriteModelAsync(viewModel);
}
await selectedFormatter.WriteAsync(outputFormatterWriteContext);
}
else
{
var json = (string) JsonConvert.SerializeObject(viewModel, GetSettings(context));
var json = (string)JsonConvert.SerializeObject(viewModel, GetSettings(context));
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(json);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#if NETCOREAPP3_1 || NET5_0
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
using System;
using System.Threading.Tasks;

namespace ServiceComposer.AspNetCore
{
static class HttpContextActionResultExtensions
{
private static readonly RouteData EmptyRouteData = new RouteData();

private static readonly ActionDescriptor EmptyActionDescriptor = new ActionDescriptor();

/// <summary>
/// Write a model to the response.
/// </summary>
public static Task WriteModelAsync<TModel>(this HttpContext context, TModel model)
{
var result = new ObjectResult(model)
{
DeclaredType = typeof(TModel)
};

return context.ExecuteResultAsync(result);
}

/// <summary>
/// Write any action result to the response.
/// </summary>
public static Task ExecuteResultAsync(this HttpContext context, IActionResult result)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (result == null) throw new ArgumentNullException(nameof(result));

var routeData = context.GetRouteData() ?? EmptyRouteData;
var actionContext = new ActionContext(context, routeData, EmptyActionDescriptor);

return result.ExecuteResultAsync(actionContext);
}
}
}
#endif
12 changes: 10 additions & 2 deletions src/ServiceComposer.AspNetCore/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@

using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace ServiceComposer.AspNetCore
{
public static class HttpRequestExtensions
{
internal static readonly string ComposedResponseModelKey = "composed-response-model";
internal static readonly string CompositionContextKey = "composition-context";
internal static readonly string ComposedActionResultKey = "composed-action-result";

public static dynamic GetComposedResponseModel(this HttpRequest request)
{
return request.HttpContext.Items[ComposedResponseModelKey];
}

#if NETCOREAPP3_1 || NET5_0
public static T GetComposedResponseModel<T>(this HttpRequest request) where T : class
{
var vm = request.HttpContext.Items[ComposedResponseModelKey];
Expand All @@ -29,7 +30,14 @@ public static T GetComposedResponseModel<T>(this HttpRequest request) where T :
$"and that the created view model is of type {typeof(T).Name}.";
throw new InvalidCastException(message);
}
#endif

public static void SetActionResult(this HttpRequest request, ActionResult actionResult)
{
if (!request.HttpContext.Items.ContainsKey(HttpRequestExtensions.ComposedActionResultKey))
{
request.HttpContext.Items.Add(HttpRequestExtensions.ComposedActionResultKey, actionResult);
}
}

internal static void SetViewModel(this HttpRequest request, dynamic viewModel)
{
Expand Down
Loading

0 comments on commit d0178f8

Please sign in to comment.