When create a Razor page, e.g. "Events.cshtml", one get its model name set to
#page
#model EventsModel
where the page's name in this case is "Events", and the URL would look like
http://example.com/Events
To be able to use page name's in Norwegian I added the following to the "Startup.cs"
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddRazorPagesOptions(options => {
options.Conventions.AddPageRoute("/Events", "/hvaskjer");
options.Conventions.AddPageRoute("/Companies", "/bedrifter");
options.Conventions.AddPageRoute("/Contact", "/kontakt");
});
With this I can also use an URL like this and still serve the "Events" page
http://example.com/hvaskjer
I'm planning to support many more languages and wonder, is this the recommended way to setup localized page name's/route's?, or is there a more proper, correct way to accomplish the same.
I mean, with the above sample, and having 15 pages in 10 languages it gets/feels messy using options.Conventions.AddPageRoute("/Page", "/side"); 150 times.
You can do this with the IPageRouteModelConvention interface. It provides access to the PageRouteModel where you can effectively add more templates for routes to match against for a particular page.
Here's a very simple proof of concept based on the following service and model:
public interface ILocalizationService
{
List<LocalRoute> LocalRoutes();
}
public class LocalizationService : ILocalizationService
{
public List<LocalRoute> LocalRoutes()
{
var routes = new List<LocalRoute>
{
new LocalRoute{Page = "/Pages/Contact.cshtml", Versions = new List<string>{"kontakt", "contacto", "contatto" } }
};
return routes;
}
}
public class LocalRoute
{
public string Page { get; set; }
public List<string> Versions { get; set; }
}
All it does is provide the list of options for a particular page. The IPageRouteModelConvention implementation looks like this:
public class LocalizedPageRouteModelConvention : IPageRouteModelConvention
{
private ILocalizationService _localizationService;
public LocalizedPageRouteModelConvention(ILocalizationService localizationService)
{
_localizationService = localizationService;
}
public void Apply(PageRouteModel model)
{
var route = _localizationService.LocalRoutes().FirstOrDefault(p => p.Page == model.RelativePath);
if (route != null)
{
foreach (var option in route.Versions)
{
model.Selectors.Add(new SelectorModel()
{
AttributeRouteModel = new AttributeRouteModel
{
Template = option
}
});
}
}
}
}
At Startup, Razor Pages build the routes for the application. The Apply method is executed for every navigable page that the framework finds. If the relative path of the current page matches one in your data, an additional template is added for each option.
You register the new convention in ConfigureServices:
services.AddMvc().AddRazorPagesOptions(options =>
{
options.Conventions.Add(new LocalizedPageRouteModelConvention(new LocalizationService()));
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
Related
I am using asp.net web-api with controllers.
I want to do a user section where one can request the site's address with the username after it like example.com/username. The other, registered routes like about, support, etc. should have a higher priority, so if you enter example.com/about, the about page should go first and if no such about page exists, it checks whether a user with such name exists. I only have found a way for SPA fallback routing, however I do not use a SPA. Got it working manually in a middleware, however it is very complicated to change it.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
string[] internalRoutes = new string[] { "", "about", "support", "support/new-request", "login", "register" };
string[] userNames = new string[] { "username1", "username2", "username3" };
app.Use(async (context, next) =>
{
string path = context.Request.Path.ToString();
path = path.Remove(0, 1);
path = path.EndsWith("/") ? path[0..^1] : path;
foreach (string route in internalRoutes)
{
if (route == path)
{
await context.Response.WriteAsync($"Requested internal page '{path}'.");
return;
}
}
foreach (string userName in userNames)
{
if (userName == path)
{
await context.Response.WriteAsync($"Requested user profile '{path}'.");
return;
}
}
await context.Response.WriteAsync($"Requested unknown page '{path}'.");
return;
await next(context);
});
app.Run();
It's really straightforward with controllers and attribute routing.
First, add controller support with app.MapControllers(); (before app.Run()).
Then, declare your controller(s) with the appropriate routing. For simplicity, I added a single one that just returns simple strings.
public class MyController : ControllerBase
{
[HttpGet("/about")]
public IActionResult About()
{
return Ok("About");
}
[HttpGet("/support")]
public IActionResult Support()
{
return Ok("Support");
}
[HttpGet("/support/new-request")]
public IActionResult SupportNewRequest()
{
return Ok("New request support");
}
[HttpGet("/{username}")]
public IActionResult About([FromRoute] string username)
{
return Ok($"Hello, {username}");
}
}
The routing table will first check if there's an exact match (e.g. for /about or /support), and if not, if will try to find a route with matching parameters (e.g. /Métoule will match the /{username} route).
I'm fairly new to ASP.NET Core Razor Pages and I'm trying to hook up the Humanizer framework into the Razor Pages view pipeline using IDisplayMetadataProvider. I'd like for all of my views to transform my model properties to "title case" strings on my labels.
For example, I'd like to do something like this:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
With the class definition above, I'd like for my labels to automatically use the Humanizer To.TitleCase for anytime I say #Html.DisplayNameFor(...):
<div>
#Html.DisplayNameFor(m => m.FirstName)
#Html.DisplayFor(m => m.FirstName)
</div>
<div>
#Html.DisplayNameFor(m => m.LastName): #Html.DisplayFor(m => m.LastName)
</div>
I'd expect to see:
First Name: Joe
Last Name: Smith
Solution (UPDATED)
I was able to configure this in classic ASP.NET MVC using the tutorial at the bottom of the Humanizer Readme page (explained here). After looking around the web for a while, I did find this post from Michael Whelan:
public class HumanizerMetadataProvider : IDisplayMetadataProvider
{
public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
{
var propertyAttributes = context.Attributes;
var modelMetadata = context.DisplayMetadata;
var propertyName = context.Key.Name;
if (IsTransformRequired(propertyName, modelMetadata, propertyAttributes))
modelMetadata.DisplayName = () => propertyName.Humanize().Transform(To.TitleCase);
}
private static bool IsTransformRequired(string propertyName, DisplayMetadata modelMetaData, IReadOnlyList<object> propertyAttributes)
{
if (!string.IsNullOrEmpty(modelMetaData.SimpleDisplayProperty))
return false;
if (propertyAttributes.OfType<DisplayNameAttribute>().Any())
return false;
if (propertyAttributes.OfType<DisplayAttribute>().Any())
return false;
if (string.IsNullOrEmpty(propertyName))
return false;
return true;
}
}
It took a little different approach with an interface, rather than inheriting from a class. This was the trick to hook into the pipeline for ASP.NET Core Razor Pages. Here was the code needed in the Startup.cs ConfigureServices(...) handler:
services
.AddRazorPages()
.AddMvcOptions(m => m.ModelMetadataDetailsProviders.Add(new HumanizerMetadataProvider()));
Hope this helps somebody!
FOUND IT
This was a PEBCAK issue. 🤦♂️
The fix was here:
if (!string.IsNullOrEmpty(modelMetaData.SimpleDisplayProperty))
return false;
Once the if statement was fixed, the "First Attempt" worked as expected.
I have the following controller setup in my solution:
[Route("api/v{VersionId}/[controller]")]
[ApiController]
[Produces("application/json")]
[Consumes("application/json")]
public class MyBaseController : ControllerBase
{
}
[ApiVersion("1.0")]
[ApiVersion("1.1")]
public class AuthenticationController : MyBaseController
{
private readonly ILoginService _loginService;
public AuthenticationController(ILoginService loginService)
{
_loginService = loginService;
}
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[HttpPost("login")]
public ActionResult<v1.JwtTokenResponse> Login([FromBody] v1.LoginRequest loginRequest)
{
var loginResult = _loginService.Login(loginRequest.Email, loginRequest.Password);
if (loginResult.StatusCode != HttpStatusCode.OK)
{
return StatusCode((int)loginResult.StatusCode);
}
var tokenResponse = new v1.JwtTokenResponse() { Token = loginResult.Token };
return Ok(tokenResponse);
}
}
Between the two versions of my API, nothing has changed for this method and so logically in my documentation I want to display that the method is still supported in the new version. Let's argue that we have a second controller of customer that has had some changed logic and hence is the reason why we have the new version 1.1 as semantic versioning dictates something new has been added but in a backwards compatible manner.
When running this code, naturally everything builds fine. The code is valid and .net core allows this sort of implementation however, when it comes to the swagger gen I am hitting issues with it producing the following error:
NotSupportedException: Conflicting method/path combination "POST api/v{VersionId}/Authentication/login" for actions - Template.Api.Endpoints.Controllers.AuthenticationController.Login (Template.Api.Endpoints),Template.Api.Endpoints.Controllers.AuthenticationController.Login (Template.Api.Endpoints). Actions require a unique method/path combination for Swagger/OpenAPI 3.0. Use ConflictingActionsResolver as a workaround
As you can see above, the path is different because the version parameter passed into the route makes it that way. Furthermore, it does not make sense to create a brand new method purely to represent that the code is available through documentation so, my question is why is swagger ignoring the version differences in the path and suggesting the user of the ConflictingActionsResolver?
Furthermore, after digging into this further and seeing that a lot of other people were having the same issue (with header versioning being a particular bugbear of the community and Swaggers hard-line approach being in conflict with this) the general approach seems to be to using the conflicting actions resolver to only take the first description it comes across which would only expose version 1.0 in the api documentation and leave out the 1.1 version giving the impression in Swagger that there is no 1.1 version of the endpoint available.
Swagger UI Config
app.UseSwaggerUI(setup =>
{
setup.RoutePrefix = string.Empty;
foreach (var description in apiVersions.ApiVersionDescriptions)
{
setup.SwaggerEndpoint($"/swagger/OpenAPISpecification{description.GroupName}/swagger.json",
description.GroupName.ToUpperInvariant());
}
});
How can we get around this and correctly display available endpoints in Swagger without having to create new methods that effectively result in a duplication of code just to satisfy what seems like an oversight in the Swagger spec? Any help would be greatly appreciated.
N.B. Many may suggest appending action on to the end of the route however we wish to avoid this as it would mean our endpoints are not restful where we want to strive for something like customers/1 with the GET, POST, PUT attributes deriving the CRUD operations without having to append something like customers/add_customer_1 or customers/add_customer_2 reflecting the method name in the URL.
This is my Swagger settings when using HeaderApiVersionReader.
public class SwaggerOptions
{
public string Title { get; set; }
public string JsonRoute { get; set; }
public string Description { get; set; }
public List<Version> Versions { get; set; }
public class Version
{
public string Name { get; set; }
public string UiEndpoint { get; set; }
}
}
In Startup#ConfigureServices
services.AddApiVersioning(apiVersioningOptions =>
{
apiVersioningOptions.AssumeDefaultVersionWhenUnspecified = true;
apiVersioningOptions.DefaultApiVersion = new ApiVersion(1, 0);
apiVersioningOptions.ReportApiVersions = true;
apiVersioningOptions.ApiVersionReader = new HeaderApiVersionReader("api-version");
});
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(swaggerGenOptions =>
{
var swaggerOptions = new SwaggerOptions();
Configuration.GetSection("Swagger").Bind(swaggerOptions);
foreach (var currentVersion in swaggerOptions.Versions)
{
swaggerGenOptions.SwaggerDoc(currentVersion.Name, new OpenApiInfo
{
Title = swaggerOptions.Title,
Version = currentVersion.Name,
Description = swaggerOptions.Description
});
}
swaggerGenOptions.DocInclusionPredicate((version, desc) =>
{
if (!desc.TryGetMethodInfo(out MethodInfo methodInfo))
{
return false;
}
var versions = methodInfo.DeclaringType.GetConstructors()
.SelectMany(constructorInfo => constructorInfo.DeclaringType.CustomAttributes
.Where(attributeData => attributeData.AttributeType == typeof(ApiVersionAttribute))
.SelectMany(attributeData => attributeData.ConstructorArguments
.Select(attributeTypedArgument => attributeTypedArgument.Value)));
return versions.Any(v => $"{v}" == version);
});
swaggerGenOptions.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"));
//... some filter settings here
});
In Startup#Configure
var swaggerOptions = new SwaggerOptions();
Configuration.GetSection("Swagger").Bind(swaggerOptions);
app.UseSwagger(option => option.RouteTemplate = swaggerOptions.JsonRoute);
app.UseSwaggerUI(option =>
{
foreach (var currentVersion in swaggerOptions.Versions)
{
option.SwaggerEndpoint(currentVersion.UiEndpoint, $"{swaggerOptions.Title} {currentVersion.Name}");
}
});
appsettings.json
{
"Swagger": {
"Title": "App title",
"JsonRoute": "swagger/{documentName}/swagger.json",
"Description": "Some text",
"Versions": [
{
"Name": "2.0",
"UiEndpoint": "/swagger/2.0/swagger.json"
},
{
"Name": "1.0",
"UiEndpoint": "/swagger/1.0/swagger.json"
}
]
}
}
There are a couple of problems.
The first issue is that the route template does not contain the route constraint. This is required when versioning by URL segment.
Therefore:
[Route("api/v{VersionId}/[controller]")]
Should be:
[Route("api/v{VersionId:apiVersion}/[controller]")]
Many examples will show using version as the route parameter name, but you can use VersionId or any other name you want.
The second problem is that you are probably creating a single OpenAPI/Swagger document. The document requires that every route template is unique. The default behavior in Swashbuckle is a document per API version. This method will produce unique paths. If you really want a single document, it is possible using URL segment versioning, but you need to expand the route templates so they produce unique paths.
Ensure your API Explorer configuration has:
services.AddVersionedApiExplorer(options => options.SubstituteApiVersionInUrl = true);
This will produce paths that expand api/v{VersionId:apiVersion}/[controller] to api/v1/Authentication and api/v1.1/Authentication respectively.
I'm new to swagger and have it installed and running but it's picking up far more API files than desired. I have been hunting a way to specify which API is documented.
You can put an ApiExplorerSettings attribute on a controller to remove it from Swagger:
[ApiExplorerSettings(IgnoreApi = true)]
public class TestApiController : ControllerBase
{
}
If you want to apply this on a lot of controllers based on some logic,
it can be done e.g. with an action model convention: https://github.com/juunas11/AspNetCoreHideRoutesFromSwagger/blob/983bad788755b4a81d2cce30f82bc28887b61924/HideRoutesFromSwagger/Controllers/SecondController.cs#L18-L28
public class ActionHidingConvention : IActionModelConvention
{
public void Apply(ActionModel action)
{
// Replace with any logic you want
if (action.Controller.ControllerName == "Second")
{
action.ApiExplorer.IsVisible = false;
}
}
}
The convention is added in ConfigureServices like:
services.AddControllers(o =>
{
o.Conventions.Add(new ActionHidingConvention());
});
I have a logic to apply in case the request received is a BadRequest, to do this I have created a filter:
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
// Apply logic
}
}
}
In Startup:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options => { options.Filters.Add<ValidateModelAttribute>(); });
}
Controller:
[Route("api/[controller]")]
[ApiController]
public class VerifyController : ControllerBase
{
[Route("test")]
[HttpPost]
[ValidateModel]
public ActionResult<Guid> validationTest(PersonalInfo personalInfo)
{
return null;
}
}
Model:
public class PersonalInfo
{
public string FirstName { get; set; }
[RegularExpression("\\d{4}-?\\d{2}-?\\d{2}", ErrorMessage = "Date must be properly formatted according to ISO 8601")]
public string BirthDate { get; set; }
}
The thing is when I put a break point on the line:
if (!context.ModelState.IsValid)
execution reaches this line only if the request I send is valid. Why it is not passing the filter if I send a bad request?
The [ApiController] attribute that you've applied to your controller adds Automatic HTTP 400 Responses to the MVC pipeline, which means that your custom filter and action aren't executed if ModelState is invalid.
I see a few options for affecting how this works:
Remove the [ApiController] attribute
Although you can just remove the [ApiController] attribute, this would also cause the loss of some of the other features it provides, such as Binding source parameter inference.
Disable only the Automatic HTTP 400 Responses
Here's an example from the docs that shows how to disable just this feature:
services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
// ...
options.SuppressModelStateInvalidFilter = true;
// ...
}
This code goes inside of your Startup's ConfigureServices method.
Customise the automatic response that gets generated
If you just want to provide a custom response to the caller, you can customise what gets returned. I've already described how this works in another answer, here.
An example of intersection for logging is describe in Log automatic 400 responses
Add configuration in Startup.ConfigureServices.
services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
// To preserve the default behavior, capture the original delegate to call later.
var builtInFactory = options.InvalidModelStateResponseFactory;
options.InvalidModelStateResponseFactory = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Startup>>();
// Perform logging here.
//E.g. logger.LogError($”{context.ModelState}”);
logger.LogWarning(context.ModelState.ModelStateErrorsToString());
// Invoke the default behavior, which produces a ValidationProblemDetails response.
// To produce a custom response, return a different implementation of IActionResult instead.
return builtInFactory(context);
};
});
public static String ModelStateErrorsToString(this ModelStateDictionary modelState)
{
IEnumerable<ModelError> allErrors = modelState.Values.SelectMany(v => v.Errors);
StringBuilder sb = new StringBuilder();
foreach (ModelError error in allErrors)
{
sb.AppendLine($"error {error.ErrorMessage} {error.Exception}");
}
return sb.ToString();
}
As the attribute filter in the life cycle of the .Net Core you can’t handle it. The filter layer with ModelState will run after the model binding.
You can handle it with .Net Core middleware as the following https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-2.1&tabs=aspnetcore2x
If you want to SuppressModelStateInvalidFilter on individual action, consider to use custom attribute suggested on https://learn.microsoft.com/en-us/answers/questions/297568/how-to-suppress-suppressmodelstateinvalidfilter-at.html. (And similar answer https://github.com/aspnet/Mvc/issues/8575)
public class SuppressModelStateInvalidFilterAttribute : Attribute, IActionModelConvention
{
private const string FilterTypeName = "ModelStateInvalidFilterFactory";
public void Apply(ActionModel action)
{
for (var i = 0; i < action.Filters.Count; i++)
{
//if (action.Filters[i] is ModelStateInvalidFilter)
if (action.Filters[i].GetType().Name == FilterTypeName)
{
action.Filters.RemoveAt(i);
break;
}
}
}
}
Example of use
[ApiController]
public class PersonController
{
[SuppressModelStateInvalidFilter]
public ActionResult<Person> Get() => new Person();
}