I have made a "standard" Web Api 2 OData project with convention model routing. Following OData queries are working:
/odata/Users
/odata/Users(123)
/odata/$metadata
/odata/Users?$select=Username
So everything seemed to be fine until I tried this, which I think is also a legal OData query:
/odata/Users(123)/Username
Slash / in query breaks everything and it does not hit the controller class and OData authentication flow at all. Should this be supported at all in Microsoft ASP.NET OData implementation? Or is this supported only if I define explicit methods with correct routes for every single property like Username? Any suggestions to fix this? I have tried explicit {*rest} routes etc.
AFAIK, the built-in routing conventions don't include one for property access. You'd be required to add many actions for every property access.
However, based on this resource here, it's not all that difficult to add a custom routing convention to handle the property access path template: ~/entityset/key/property
Here's a custom routing convention adapted from the link I shared above
Assembly used: Microsoft.AspNet.OData 7.4.1 - the approach would be the same for any other OData Web API library you might be using
Class used for illustration
public class Product
{
[Key]
public int Id { get; set; }
public string Name { get; set; }
}
Add routing convention for property access
// Usings
using Microsoft.AspNet.OData.Routing;
using Microsoft.AspNet.OData.Routing.Conventions;
using System;
using System.Linq;
using System.Web.Http.Controllers;
// ...
public class CustomPropertyRoutingConvention : NavigationSourceRoutingConvention
{
private const string ActionName = "GetProperty";
public override string SelectAction(ODataPath odataPath, HttpControllerContext controllerContext, ILookup<string, HttpActionDescriptor> actionMap)
{
if (odataPath == null || controllerContext == null || actionMap == null)
{
return null;
}
if (odataPath.PathTemplate == "~/entityset/key/property" ||
odataPath.PathTemplate == "~/entityset/key/cast/property" ||
odataPath.PathTemplate == "~/singleton/property" ||
odataPath.PathTemplate == "~/singleton/cast/property")
{
var segment = odataPath.Segments.OfType<Microsoft.OData.UriParser.PropertySegment>().LastOrDefault();
if (segment != null)
{
string actionName = FindMatchingAction(actionMap, ActionName);
if (actionName != null)
{
if (odataPath.PathTemplate.StartsWith("~/entityset/key", StringComparison.Ordinal))
{
var keySegment = odataPath.Segments.OfType<Microsoft.OData.UriParser.KeySegment>().FirstOrDefault();
if (keySegment == null || !keySegment.Keys.Any())
throw new InvalidOperationException("This link does not contain a key.");
controllerContext.RouteData.Values[ODataRouteConstants.Key] = keySegment.Keys.First().Value;
}
controllerContext.RouteData.Values["propertyName"] = segment.Property.Name;
return actionName;
}
}
}
return null;
}
public static string FindMatchingAction(ILookup<string, HttpActionDescriptor> actionMap, params string[] targetActionNames)
{
foreach (string targetActionName in targetActionNames)
{
if (actionMap.Contains(targetActionName))
{
return targetActionName;
}
}
return null;
}
}
Add single method in your controller to handle request for any property
public class ProductsController : ODataController
{
// ...
[HttpGet]
public IHttpActionResult GetProperty(int key, string propertyName)
{
var product = _db.Products.FirstOrDefault(d => d.Id.Equals(key));
if (product == null)
{
return NotFound();
}
PropertyInfo info = typeof(Product).GetProperty(propertyName);
object value = info.GetValue(product);
return Ok(value, value.GetType());
}
private IHttpActionResult Ok(object content, Type type)
{
var resultType = typeof(OkNegotiatedContentResult<>).MakeGenericType(type);
return Activator.CreateInstance(resultType, content, this) as IHttpActionResult;
}
// ...
}
In your WebApiConfig.cs (or equivalent place where you configure the service)
var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Product>("Products");
var routingConventions = ODataRoutingConventions.CreateDefaultWithAttributeRouting("odata", configuration);
routingConventions.Insert(0, new CustomPropertyRoutingConvention());
configuration.MapODataServiceRoute("odata", "odata", modelBuilder.GetEdmModel(), new DefaultODataPathHandler(), routingConventions);
configuration.Count().Filter().OrderBy().Expand().Select().MaxTop(null);
configuration.EnsureInitialized();
Request for Name property: /Products(1)/Name
Request for Id property: /Products(1)/Id
Related
It seems swashbuckle\swagger-ui (5.6 - using swagger-ui) does not generate example XML correctly when the model has a property that is a list.
To see this issue:
1 - Create an empty webapi project (I'm using asp.net)
2 - Add a couple of example models (I went with Customer + Order for testing)
public class Customer
{
public string AccountNumber { get; set; }
[XmlArray("Orders"),XmlArrayItem("Order")]
public List<Order> Orders { get;set; }
}
public class Order
{
public string OrderNumber { get;set; }
}
3 - Create a controller using FromBody to bind to model
public class CustomerController : ApiController
{
public void Post([FromBody]Customer customer)
{
customer.ToString();
}
}
4 - Change web api config to allow simple XML
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Web API configuration and services
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.Formatters.XmlFormatter.UseXmlSerializer = true; //ADD THIS
}
}
5 - Run site and using /swagger ui change parameter content type to xml and select example model. You will find the example is as follows.
<?xml version="1.0"?>
<Customer>
<AccountNumber>string</AccountNumber>
<Orders>
<OrderNumber>string</OrderNumber>
</Orders>
</Customer>
6 - Submit this with a breakpoint on the customer.ToString() line in the controller and you will find the Orders collection is empty
7 - Modify the XML in swagger-ui to the following and submit:
<?xml version="1.0"?>
<Customer>
<AccountNumber>string</AccountNumber>
<Orders>
<Order><OrderNumber>string</OrderNumber></Order>
</Orders>
</Customer>
8 - The Customer.Orders collection is now correctly populated.
What is the best way to fix or workaround this in Swashbuckle?
(There are a few discussions around this and whether it's a bug in swagger-ui or Swashbuckle, but I'm specifically interested in working around it using Swashbuckle)
I have found the following works:
1 - Add an implementation of ISchemaFilter
internal class ApplySchemaVendorExtensions : ISchemaFilter
{
public void Apply(Schema schema, SchemaRegistry schemaRegistry, Type type)
{
// Fix issues with xml array examples not generating correctly
if (!type.IsValueType)
{
schema.xml = new Xml { name = type.Name };
if(schema.properties != null)
{
foreach (var property in schema.properties)
{
//Array property, which wraps its elements
if (property.Value.type == "array")
{
property.Value.xml = new Xml
{
name = $"{property.Key}",
wrapped = true
};
}
}
}
}
}
}
2 - Comment this line into SwaggerConfig.cs
c.SchemaFilter<ApplySchemaVendorExtensions>();
Repeat the test in the Question and the example XML now works directly. As always I'm curious if there's a better solution...
EDIT: Actually this oddly works in the original project I have this issue, but in the small reproduction project for this Quesion it behaves slightly differently! I will edit this answer when I find why...
Thanks to #mutex, but I found that I needed to make another adjustment to it:
internal class SwaggerFixArraysInXmlFilter : Swashbuckle.Swagger.ISchemaFilter
{
// this fixes a Swagger bug that wasn't generating correct XML elements around List<> or array[] types
public void Apply(Swashbuckle.Swagger.Schema schema, Swashbuckle.Swagger.SchemaRegistry schemaRegistry, System.Type type)
{
// Fix issues with xml array examples not generating correctly
if (!type.IsValueType)
{
schema.xml = new Swashbuckle.Swagger.Xml { name = type.Name };
if (schema.properties != null)
{
foreach (var property in schema.properties)
{
//Array property, which wraps its elements
if (property.Value.type == "array")
{
property.Value.xml = new Swashbuckle.Swagger.Xml
{
name = $"{property.Key}",
wrapped = true
};
property.Value.items.xml = new Swashbuckle.Swagger.Xml
{
name = $"{property.Value.items.type}",
wrapped = true
};
}
}
}
}
}
}
Thanks to #Abacus, but I found that I needed to make another adjustment to it. (String is not a ValueType, so it was renaming any string value to <String>string</String>... May have something to do with .NET Core 3.1)
internal class SwaggerFixArraysInXmlFilter : Swashbuckle.Swagger.ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
Type type = context.Type;
// Fix issues with xml array examples not generating correctly
if (!type.IsValueType && type.Name != "String")
{
schema.Xml = new OpenApiXml { Name = type.Name };
if (schema.Properties != null)
{
foreach (var property in schema.Properties)
{
//Array property, which wraps its elements
if (property.Value.Type == "array")
{
property.Value.Xml = new OpenApiXml
{
Name = $"{property.Key}",
Wrapped = true
};
property.Value.Items.Xml = new OpenApiXml
{
Name = $"{property.Value.Items.Type}",
Wrapped = true
};
}
}
}
}
}
If you are using .Net Core 2.2 with Swagger v5, you will need the below code set
internal class SwaggerFixArraysInXmlFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
Type type = context.Type;
// Fix issues with xml array examples not generating correctly
if (!type.IsValueType)
{
schema.Xml = new OpenApiXml { Name = type.Name };
if (schema.Properties != null)
{
foreach (var property in schema.Properties)
{
//Array property, which wraps its elements
if (property.Value.Type == "array")
{
property.Value.Xml = new OpenApiXml
{
Name = $"{property.Key}",
Wrapped = true
};
property.Value.Items.Xml = new OpenApiXml
{
Name = $"{property.Value.Items.Type}",
Wrapped = true
};
}
}
}
}
}
}
I was wondering how I can achieve model validation with ASP.NET Web API. I have my model like so:
public class Enquiry
{
[Key]
public int EnquiryId { get; set; }
[Required]
public DateTime EnquiryDate { get; set; }
[Required]
public string CustomerAccountNumber { get; set; }
[Required]
public string ContactName { get; set; }
}
I then have a Post action in my API Controller:
public void Post(Enquiry enquiry)
{
enquiry.EnquiryDate = DateTime.Now;
context.DaybookEnquiries.Add(enquiry);
context.SaveChanges();
}
How do I add if(ModelState.IsValid) and then handle the error message to pass down to the user?
For separation of concern, I would suggest you use action filter for model validation, so you don't need to care much how to do validation in your api controller:
using System.Net;
using System.Net.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Filters;
namespace System.Web.Http.Filters
{
public class ValidationActionFilter : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)
{
var modelState = actionContext.ModelState;
if (!modelState.IsValid)
actionContext.Response = actionContext.Request
.CreateErrorResponse(HttpStatusCode.BadRequest, modelState);
}
}
}
Maybe not what you were looking for, but perhaps nice for someone to know:
If you are using .net Web Api 2 you could just do the following:
if (!ModelState.IsValid)
return BadRequest();
Depending on the model errors, you get this result:
{
Message: "The request is invalid."
ModelState: {
model.PropertyA: [
"The PropertyA field is required."
],
model.PropertyB: [
"The PropertyB field is required."
]
}
}
Like this, for example:
public HttpResponseMessage Post(Person person)
{
if (ModelState.IsValid)
{
PersonDB.Add(person);
return Request.CreateResponse(HttpStatusCode.Created, person);
}
else
{
// the code below should probably be refactored into a GetModelErrors
// method on your BaseApiController or something like that
var errors = new List<string>();
foreach (var state in ModelState)
{
foreach (var error in state.Value.Errors)
{
errors.Add(error.ErrorMessage);
}
}
return Request.CreateResponse(HttpStatusCode.Forbidden, errors);
}
}
This will return a response like this (assuming JSON, but same basic principle for XML):
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
(some headers removed here)
["A value is required.","The field First is required.","Some custom errorm essage."]
You can of course construct your error object/list any way you like, for example adding field names, field id's etc.
Even if it's a "one way" Ajax call like a POST of a new entity, you should still return something to the caller - something that indicates whether or not the request was successful. Imagine a site where your user will add some info about themselves via an AJAX POST request. What if the information they have tried to entered isn't valid - how will they know if their Save action was successful or not?
The best way to do this is using Good Old HTTP Status Codes like 200 OK and so on. That way your JavaScript can properly handle failures using the correct callbacks (error, success etc).
Here's a nice tutorial on a more advanced version of this method, using an ActionFilter and jQuery: http://asp.net/web-api/videos/getting-started/custom-validation
Or, if you are looking for simple collection of errors for your apps.. here is my implementation of this:
public override void OnActionExecuting(HttpActionContext actionContext)
{
var modelState = actionContext.ModelState;
if (!modelState.IsValid)
{
var errors = new List<string>();
foreach (var state in modelState)
{
foreach (var error in state.Value.Errors)
{
errors.Add(error.ErrorMessage);
}
}
var response = new { errors = errors };
actionContext.Response = actionContext.Request
.CreateResponse(HttpStatusCode.BadRequest, response, JsonMediaTypeFormatter.DefaultMediaType);
}
}
Error Message Response will look like:
{
"errors": [
"Please enter a valid phone number (7+ more digits)",
"Please enter a valid e-mail address"
]
}
You can use attributes from the System.ComponentModel.DataAnnotations namespace to set validation rules. Refer Model Validation - By Mike Wasson for details.
Also refer video ASP.NET Web API, Part 5: Custom Validation - Jon Galloway
Other References
Take a Walk on the Client Side with WebAPI and WebForms
How ASP.NET Web API binds HTTP messages to domain models, and how to work with media formats in Web API.
Dominick Baier - Securing ASP.NET Web APIs
Hooking AngularJS validation to ASP.NET Web API Validation
Displaying ModelState Errors with AngularJS in ASP.NET MVC
How to render errors to client? AngularJS/WebApi ModelState
Dependency-Injected Validation in Web API
Add below code in startup.cs file
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2).ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = (context) =>
{
var errors = context.ModelState.Values.SelectMany(x => x.Errors.Select(p => new ErrorModel()
{
ErrorCode = ((int)HttpStatusCode.BadRequest).ToString(CultureInfo.CurrentCulture),
ErrorMessage = p.ErrorMessage,
ServerErrorMessage = string.Empty
})).ToList();
var result = new BaseResponse
{
Error = errors,
ResponseCode = (int)HttpStatusCode.BadRequest,
ResponseMessage = ResponseMessageConstants.VALIDATIONFAIL,
};
return new BadRequestObjectResult(result);
};
});
C#
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)
{
if (actionContext.ModelState.IsValid == false)
{
actionContext.Response = actionContext.Request.CreateErrorResponse(
HttpStatusCode.BadRequest, actionContext.ModelState);
}
}
}
...
[ValidateModel]
public HttpResponseMessage Post([FromBody]AnyModel model)
{
Javascript
$.ajax({
type: "POST",
url: "/api/xxxxx",
async: 'false',
contentType: "application/json; charset=utf-8",
data: JSON.stringify(data),
error: function (xhr, status, err) {
if (xhr.status == 400) {
DisplayModelStateErrors(xhr.responseJSON.ModelState);
}
},
....
function DisplayModelStateErrors(modelState) {
var message = "";
var propStrings = Object.keys(modelState);
$.each(propStrings, function (i, propString) {
var propErrors = modelState[propString];
$.each(propErrors, function (j, propError) {
message += propError;
});
message += "\n";
});
alert(message);
};
Here you can check to show the model state error one by one
public HttpResponseMessage CertificateUpload(employeeModel emp)
{
if (!ModelState.IsValid)
{
string errordetails = "";
var errors = new List<string>();
foreach (var state in ModelState)
{
foreach (var error in state.Value.Errors)
{
string p = error.ErrorMessage;
errordetails = errordetails + error.ErrorMessage;
}
}
Dictionary<string, object> dict = new Dictionary<string, object>();
dict.Add("error", errordetails);
return Request.CreateResponse(HttpStatusCode.BadRequest, dict);
}
else
{
//do something
}
}
}
I had an issue implementing the accepted solution pattern where my ModelStateFilter would always return false (and subsequently a 400) for actionContext.ModelState.IsValid for certain model objects:
public class ModelStateFilter : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)
{
if (!actionContext.ModelState.IsValid)
{
actionContext.Response = new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest};
}
}
}
I only accept JSON, so I implemented a custom model binder class:
public class AddressModelBinder : System.Web.Http.ModelBinding.IModelBinder
{
public bool BindModel(HttpActionContext actionContext, System.Web.Http.ModelBinding.ModelBindingContext bindingContext)
{
var posted = actionContext.Request.Content.ReadAsStringAsync().Result;
AddressDTO address = JsonConvert.DeserializeObject<AddressDTO>(posted);
if (address != null)
{
// moar val here
bindingContext.Model = address;
return true;
}
return false;
}
}
Which I register directly after my model via
config.BindParameter(typeof(AddressDTO), new AddressModelBinder());
You can also throw exceptions as documented here:
http://blogs.msdn.com/b/youssefm/archive/2012/06/28/error-handling-in-asp-net-webapi.aspx
Note, to do what that article suggests, remember to include System.Net.Http
Put this in the startup.cs file
services.AddMvc().ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = (context) =>
{
var errors = context.ModelState.Values.SelectMany(x => x.Errors.Select(p =>p.ErrorMessage)).ToList();
var result = new Response
{
Succeeded = false,
ResponseMessage = string.Join(", ",errors)
};
return new BadRequestObjectResult(result);
};
});
I am using ASP.NET Web Api 2 framework and using a basic route constraint as below.
[Route("Number/{id:int:min(2):max(10)}")]
public HttpResponseMessage GetNumber([FromUri] int id)
{
return (id > 0)
? Request.CreateResponse(HttpStatusCode.OK, id)
: Request.CreateResponse(HttpStatusCode.PreconditionFailed);
}
I would like to know when the id is conflict with the constraint above, .e.g. 1 or 11,
how can I overrride the default HTTP Status Return code which is 404?
Thanks very much.
You can create a custom route constraint that will check the min, max values like you wish, and additionally allow you to specify the HttpStatusCode in case the constraint is not fulfilled correctly.
public class RangeWithStatusRouteConstraint : IHttpRouteConstraint
{
private readonly int _from;
private readonly int _to;
private readonly HttpStatusCode _statusCode;
public RangeWithStatusRouteConstraint(int from, int to, string statusCode)
{
_from = from;
_to = to;
if (!Enum.TryParse(statusCode, true, out _statusCode))
{
_statusCode = HttpStatusCode.NotFound;
}
}
public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values,
HttpRouteDirection routeDirection)
{
object value;
if (values.TryGetValue(parameterName, out value) && value != null)
{
var stringValue = value as string;
var intValue = 0;
if (stringValue != null && int.TryParse(stringValue, out intValue))
{
if (intValue >= _from && intValue <= _to)
{
return true;
}
//only throw if we had the expected type of value
//but it fell out of range
throw new HttpResponseException(_statusCode);
}
}
return false;
}
}
Now you need to register it in the attribute mapping:
var constraintResolver = new DefaultInlineConstraintResolver();
constraintResolver.ConstraintMap.Add("rangeWithStatus", typeof(RangeWithStatusRouteConstraint));
config.MapHttpAttributeRoutes(constraintResolver);
And your route can now look like this:
public class MyController : ApiController
{
[Route("Number/{id:int:rangeWithStatus(2, 10, PreconditionFailed)}")]
public HttpResponseMessage GetNumber([FromUri] int id)
{
return Request.CreateResponse(HttpStatusCode.OK, id);
}
}
in this case Conflict is a string representation of HttpStatusCode.Conflict enumeration, which is later cast to the enum value in the constraint.
With such setup, if the value falls out of the [2, 10] range, the Web API infrastructure will respond with 409 status code instead of the default 404.
Remove the constraint on the route, and do the validation inside the method.
Constraints are used to figure out if a route should be used at all, but you want to use the route to be hit but change the status code if the input falls outside some parameters.
[Route("Number/{id})] // No constraints
public HttpResponseMessage GetNumber([FromUri] int id)
{
return (id > 1 && id < 11) // here you validate id range
? Request.CreateResponse(HttpStatusCode.OK, id)
: Request.CreateResponse(HttpStatusCode.PreconditionFailed);
}
I think that the correct solution is to check constraint inside controller (as #aanund suggested). However if you want to keep the Route constraints and avoid conditionals in your code, you may create another action controller with the same route but without constraints. If the constraints are not verified the new action controller will be called:
[Route("Number/{id:int:min(2):max(10)}")]
public HttpResponseMessage GetNumber([FromUri] int id)
{
Request.CreateResponse(HttpStatusCode.OK, id)
}
[Route("Number/{id}")]
public HttpResponseMessage GetNumber2([FromUri] int id)
{
Request.CreateResponse(HttpStatusCode.PreconditionFailed);
}
I'm trying to use Hyprlinkr to generate URL to the HTTP Post action. My controller looks like this:
public class MyController : ApiController {
[HttpPost]
public void DoSomething([FromBody]SomeDto someDto) {
...
}
}
with this route:
routes.MapHttpRoute(
name: "MyRoute",
routeTemplate: "dosomething",
defaults: new { controller = "My", action = "DoSomething" });
I expect to get a simple URL: http://example.com/dosomething, but it does not work. I tried two methods:
1) routeLinker.GetUri(c => c.DoSomething(null)) - throws NullReferenceException
2) routeLinker.GetUri(c => c.DoSomething(new SomeDto())) - generates invalid URL:
http://example.com/dosomething?someDto=Namespace.SomeDto
Update:
Issue opened at github:
https://github.com/ploeh/Hyprlinkr/issues/17
I found a workaround, loosely based on Mark's answer. The idea is to go over every route parameter and remove those that have [FromBody] attribute applied to them. This way dispatcher does not need to be modified for every new controller or action.
public class BodyParametersRemover : IRouteDispatcher {
private readonly IRouteDispatcher _defaultDispatcher;
public BodyParametersRemover(String routeName) {
if (routeName == null) {
throw new ArgumentNullException("routeName");
}
_defaultDispatcher = new DefaultRouteDispatcher(routeName);
}
public Rouple Dispatch(
MethodCallExpression method,
IDictionary<string, object> routeValues) {
var routeKeysToRemove = new HashSet<string>();
foreach (var paramName in routeValues.Keys) {
var parameter = method
.Method
.GetParameters()
.FirstOrDefault(p => p.Name == paramName);
if (parameter != null) {
if (IsFromBodyParameter(parameter)) {
routeKeysToRemove.Add(paramName);
}
}
}
foreach (var routeKeyToRemove in routeKeysToRemove) {
routeValues.Remove(routeKeyToRemove);
}
return _defaultDispatcher.Dispatch(method, routeValues);
}
private Boolean IsFromBodyParameter(ParameterInfo parameter) {
var attributes = parameter.CustomAttributes;
return attributes.Any(
ct => ct.AttributeType == typeof (FromBodyAttribute));
}
}
The second option is the way to go:
routeLinker.GetUri(c => c.DoSomething(new SomeDto()))
However, when using a POST method, you'll need to remove the model part of the generated URL. You can do that with a custom route dispatcher:
public ModelFilterRouteDispatcher : IRouteDispatcher
{
private readonly IRouteDispatcher defaultDispatcher;
public ModelFilterRouteDispatcher()
{
this.defaultDispatcher = new DefaultRouteDispatcher("DefaultApi");
}
public Rouple Dispatch(
MethodCallExpression method,
IDictionary<string, object> routeValues)
{
if (method.Method.ReflectedType == typeof(MyController))
{
var rv = new Dictionary<string, object>(routeValues);
rv.Remove("someDto");
return new Rouple("MyRoute", rv);
}
return this.defaultDispatcher.Dispatch(method, routeValues);
}
}
Now pass that custom dispatcher into your RouteLinker instance.
Caveat: it's very late as I'm writing this and I haven't attempted to compile the above code, but I thought I'd rather throw an attempted answer here than have you wait several more days.
Dimitry's solution got me most of the way to where I wanted, however the routeName ctor param was a problem because StructureMap doesn't know what to put in there. Internally hyprlink is using UrlHelper to generate the URI, and that wants to know the route name to use
At that point, I see why URI generation is so tricky, because it is tied to the route names in the routing config and in order to support POST, we need to associate the method, with the correct routename and that is not known at dispatcher ctor time. Default hyprlinkr assumes there is only one route config named "DefaultRoute"
I changed Dimitry's code as follows, and adopted a convention based approach, where controller methods that start with "Get" are mapped to the route named "Get" and controller methods starting with "Add" are mapped to the route named "Add".
I wonder if there are better ways of associating a method with the proper named routeConfig?
public class RemoveFromBodyParamsRouteDispatcher : IRouteDispatcher
{
private static readonly ILog _log = LogManager.GetLogger(typeof (RemoveFromBodyParamsRouteDispatcher));
public Rouple Dispatch(MethodCallExpression method,
IDictionary<string, object> routeValues)
{
var methodName = method.Method.Name;
DefaultRouteDispatcher defaultDispatcher;
if (methodName.StartsWith("Get"))
defaultDispatcher = new DefaultRouteDispatcher("Get");
else if (methodName.StartsWith("Add"))
defaultDispatcher = new DefaultRouteDispatcher("Add");
else
throw new Exception("Unable to determine correct route name for method with name " + methodName);
_log.Debug("Dispatch methodName=" + methodName);
//make a copy of routeValues as contract says we should not modify
var routeValuesWithoutFromBody = new Dictionary<string, object>(routeValues);
var routeKeysToRemove = new HashSet<string>();
foreach (var paramName in routeValuesWithoutFromBody.Keys)
{
var parameter = method.Method
.GetParameters()
.FirstOrDefault(p => p.Name == paramName);
if (parameter != null)
if (IsFromBodyParameter(parameter))
{
_log.Debug("Dispatch: Removing paramName=" + paramName);
routeKeysToRemove.Add(paramName);
}
}
foreach (var routeKeyToRemove in routeKeysToRemove)
routeValuesWithoutFromBody.Remove(routeKeyToRemove);
return defaultDispatcher.Dispatch(method, routeValuesWithoutFromBody);
}
private static bool IsFromBodyParameter(ParameterInfo parameter)
{
//Apparently the "inherit" argument is ignored: http://msdn.microsoft.com/en-us/library/cwtf69s6(v=vs.100).aspx
const bool msdnSaysThisArgumentIsIgnored = true;
var attributes = parameter.GetCustomAttributes(msdnSaysThisArgumentIsIgnored);
return attributes.Any(ct => ct is FromBodyAttribute);
}
}
In the _Layout.cshtml file, I have a section at the bottom of the body called "ScriptsContent" declared like this:
#RenderSection("ScriptsContent", required: false)
In my view, I can then use this section to add scripts to be executed. But what if I also have a PartialView that also need to use this section to add additional scripts?
View
#section ScriptsContent
{
<script type="text/javascript">
alert(1);
</script>
}
#Html.Partial("PartialView")
PartialView
#section ScriptsContent
{
<script type="text/javascript">
alert(2);
</script>
}
Result
Only the first script is rendered. The second script doesn't exist in source code of the webpage.
Razor seems to only output the first #section ScriptsContent that it sees. What I would like to know is if there's a way to merge each call to the section.
If we cannot do this, what do you propose?
Here's a solution for that problem. It's from this blog: http://blog.logrythmik.com/post/A-Script-Block-Templated-Delegate-for-Inline-Scripts-in-Razor-Partials.aspx
public static class ViewPageExtensions
{
private const string SCRIPTBLOCK_BUILDER = "ScriptBlockBuilder";
public static MvcHtmlString ScriptBlock(this WebViewPage webPage, Func<dynamic, HelperResult> template)
{
if (!webPage.IsAjax)
{
var scriptBuilder = webPage.Context.Items[SCRIPTBLOCK_BUILDER] as StringBuilder ?? new StringBuilder();
scriptBuilder.Append(template(null).ToHtmlString());
webPage.Context.Items[SCRIPTBLOCK_BUILDER] = scriptBuilder;
return new MvcHtmlString(string.Empty);
}
return new MvcHtmlString(template(null).ToHtmlString());
}
public static MvcHtmlString WriteScriptBlocks(this WebViewPage webPage)
{
var scriptBuilder = webPage.Context.Items[SCRIPTBLOCK_BUILDER] as StringBuilder ?? new StringBuilder();
return new MvcHtmlString(scriptBuilder.ToString());
}
}
so anywwhere in your View or PartialView you can use this:
#this.ScriptBlock(
#<script type='text/javascript'>
alert(1);
</script>
)
and in your _Layout or MasterView, use this:
#this.WriteScriptBlocks()
There is no way to share sections between a view and partial views.
Absent a ScriptManager-like solution, you could have a collection of script files (initialized in your view and stored either in HttpContext.Items or in ViewData) to which the partial view would append the script file names it requires. Then towards the end of your view you would declare a section that fetches that collection and emits the right script tags.
The problem with the accepted answer is that it breaks Output Caching. The trick to solving this is to overwrite the OutputCache attribute with your own implementation. Unfortunately we can't extend the original attribute since it has lots of internal methods which we need to access.
I actually use Donut Output Caching which overwrites the OutputCache attribute itself. There are alternative libraries which also use their own OutputCache attribute so I will explain the steps I made to get it to work so that you can apply it to whichever one you're using.
First you need to copy the existing OutputCache attribute and place it within your application. You can get the existing attribute by looking at the source code.
Now add the following property to the class. This is where we store the script blocks so we can render the correct ones when retrieving from the cache.
public static ConcurrentDictionary<string, StringBuilder> ScriptBlocks = new ConcurrentDictionary<string, StringBuilder>();
Now inside the OnActionExecuting method you need to store the cache key (the unique identifier for the output cache) inside the current requests collection. For example:
filterContext.HttpContext.Items["OutputCacheKey"] = cacheKey;
Now modify the ViewPageExtensions class by adding the following (replacing CustomOutputCacheAttribute with the name of your attribute):
var outputCacheKey = webPage.Context.Items["OutputCacheKey"] as string;
if (outputCacheKey != null)
CustomOutputCacheAttribute.ScriptBlocks.AddOrUpdate(outputCacheKey, new StringBuilder(template(null).ToHtmlString()), (k, sb) => {
sb.Append(template(null).ToHtmlString());
return sb;
});
before:
return new MvcHtmlString(string.Empty);
Note: For a slight performance boost you'll also want to make sure you only call "template(null).ToHtmlString()" once.
Now return to your custom OutputCache attribute and add the following only when you are retrieving from the cache inside the OnActionExecuting method:
if (ScriptBlocks.ContainsKey(cacheKey)) {
var scriptBuilder = filterContext.HttpContext.Items["ScriptBlockBuilder"] as StringBuilder ?? new StringBuilder();
scriptBuilder.Append(ScriptBlocks[cacheKey].ToString());
filterContext.HttpContext.Items["ScriptBlockBuilder"] = scriptBuilder;
}
Here's the final code of my attribute:
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
using System.Web.UI;
using DevTrends.MvcDonutCaching;
public class CustomOutputCacheAttribute : ActionFilterAttribute, IExceptionFilter {
private readonly IKeyGenerator _keyGenerator;
private readonly IDonutHoleFiller _donutHoleFiller;
private readonly IExtendedOutputCacheManager _outputCacheManager;
private readonly ICacheSettingsManager _cacheSettingsManager;
private readonly ICacheHeadersHelper _cacheHeadersHelper;
private bool? _noStore;
private CacheSettings _cacheSettings;
public int Duration { get; set; }
public string VaryByParam { get; set; }
public string VaryByCustom { get; set; }
public string CacheProfile { get; set; }
public OutputCacheLocation Location { get; set; }
public bool NoStore {
get { return _noStore ?? false; }
set { _noStore = value; }
}
public static ConcurrentDictionary<string, StringBuilder> ScriptBlocks = new ConcurrentDictionary<string, StringBuilder>();
public DonutOutputCacheAttribute() {
var keyBuilder = new KeyBuilder();
_keyGenerator = new KeyGenerator(keyBuilder);
_donutHoleFiller = new DonutHoleFiller(new EncryptingActionSettingsSerialiser(new ActionSettingsSerialiser(), new Encryptor()));
_outputCacheManager = new OutputCacheManager(OutputCache.Instance, keyBuilder);
_cacheSettingsManager = new CacheSettingsManager();
_cacheHeadersHelper = new CacheHeadersHelper();
Duration = -1;
Location = (OutputCacheLocation)(-1);
}
public override void OnActionExecuting(ActionExecutingContext filterContext) {
_cacheSettings = BuildCacheSettings();
var cacheKey = _keyGenerator.GenerateKey(filterContext, _cacheSettings);
if (_cacheSettings.IsServerCachingEnabled) {
var cachedItem = _outputCacheManager.GetItem(cacheKey);
if (cachedItem != null) {
filterContext.Result = new ContentResult {
Content = _donutHoleFiller.ReplaceDonutHoleContent(cachedItem.Content, filterContext),
ContentType = cachedItem.ContentType
};
if (ScriptBlocks.ContainsKey(cacheKey)) {
var scriptBuilder = filterContext.HttpContext.Items["ScriptBlockBuilder"] as StringBuilder ?? new StringBuilder();
scriptBuilder.Append(ScriptBlocks[cacheKey].ToString());
filterContext.HttpContext.Items["ScriptBlockBuilder"] = scriptBuilder;
}
}
}
if (filterContext.Result == null) {
filterContext.HttpContext.Items["OutputCacheKey"] = cacheKey;
var cachingWriter = new StringWriter(CultureInfo.InvariantCulture);
var originalWriter = filterContext.HttpContext.Response.Output;
filterContext.HttpContext.Response.Output = cachingWriter;
filterContext.HttpContext.Items[cacheKey] = new Action<bool>(hasErrors => {
filterContext.HttpContext.Items.Remove(cacheKey);
filterContext.HttpContext.Response.Output = originalWriter;
if (!hasErrors) {
var cacheItem = new CacheItem {
Content = cachingWriter.ToString(),
ContentType = filterContext.HttpContext.Response.ContentType
};
filterContext.HttpContext.Response.Write(_donutHoleFiller.RemoveDonutHoleWrappers(cacheItem.Content, filterContext));
if (_cacheSettings.IsServerCachingEnabled && filterContext.HttpContext.Response.StatusCode == 200)
_outputCacheManager.AddItem(cacheKey, cacheItem, DateTime.UtcNow.AddSeconds(_cacheSettings.Duration));
}
});
}
}
public override void OnResultExecuted(ResultExecutedContext filterContext) {
ExecuteCallback(filterContext, false);
if (!filterContext.IsChildAction)
_cacheHeadersHelper.SetCacheHeaders(filterContext.HttpContext.Response, _cacheSettings);
}
public void OnException(ExceptionContext filterContext) {
if (_cacheSettings != null)
ExecuteCallback(filterContext, true);
}
private void ExecuteCallback(ControllerContext context, bool hasErrors) {
var cacheKey = _keyGenerator.GenerateKey(context, _cacheSettings);
var callback = context.HttpContext.Items[cacheKey] as Action<bool>;
if (callback != null)
callback.Invoke(hasErrors);
}
private CacheSettings BuildCacheSettings() {
CacheSettings cacheSettings;
if (string.IsNullOrEmpty(CacheProfile)) {
cacheSettings = new CacheSettings {
IsCachingEnabled = _cacheSettingsManager.IsCachingEnabledGlobally,
Duration = Duration,
VaryByCustom = VaryByCustom,
VaryByParam = VaryByParam,
Location = (int)Location == -1 ? OutputCacheLocation.Server : Location,
NoStore = NoStore
};
} else {
var cacheProfile = _cacheSettingsManager.RetrieveOutputCacheProfile(CacheProfile);
cacheSettings = new CacheSettings {
IsCachingEnabled = _cacheSettingsManager.IsCachingEnabledGlobally && cacheProfile.Enabled,
Duration = Duration == -1 ? cacheProfile.Duration : Duration,
VaryByCustom = VaryByCustom ?? cacheProfile.VaryByCustom,
VaryByParam = VaryByParam ?? cacheProfile.VaryByParam,
Location = (int)Location == -1 ? ((int)cacheProfile.Location == -1 ? OutputCacheLocation.Server : cacheProfile.Location) : Location,
NoStore = _noStore.HasValue ? _noStore.Value : cacheProfile.NoStore
};
}
if (cacheSettings.Duration == -1)
throw new HttpException("The directive or the configuration settings profile must specify the 'duration' attribute.");
if (cacheSettings.Duration < 0)
throw new HttpException("The 'duration' attribute must have a value that is greater than or equal to zero.");
return cacheSettings;
}
}
I also had to modify the Donut Output Cache library to make IExtendedOutputCacheManager and the OutputCacheManager constructor public.
Please note this has been extracted from my application and may require some minor tweaks. You should also place WriteScriptBlocks at the bottom of the page so it is not called until after all child actions are triggered.
Hope this helps.