Put a middleware-like between RazorPage and UrlHelper - c#

I'm working on an multilingual website. earlier, i used to use QueryStringRequestProvider for RequestLocalizationOptions. Finally, based on some SEO-friendly best practices i decided to migrate to RouteDataRequestCultureProvider. I have many problems in this way. i've already checked more than 100 github issues and stackoverflow posts to find a working solution, but still nothing.
Currently i used these codes to partial reach the goal
Startup.cs
services.Configure<RequestLocalizationOptions>(options =>
{
options.ConfigureRequestLocalization();
options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider
{
RouteDataStringKey = LanguageRouteConstraint.Key,
Options = options
});
});
services.AddRazorPages()
.AddRazorPagesOptions(options =>
{
options.Conventions.Add(new LocalizedPageRouteModelConvention());
});
LanguageRouteConstraint.cs
public class LanguageRouteConstraint : IRouteConstraint
{
public const string Key = "culture";
public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
{
if (!values.ContainsKey(Key))
return false;
var lang = values[routeKey] as string;
if (string.IsNullOrWhiteSpace(lang))
return false;
return lang == "tr" || lang == "en" || lang == "fa";
}
}
Cultures.cs
public static class Cultures
{
public static readonly string[] Supported = { "tr", "en", "fa" };
public static readonly string Default = Supported[0];
public static RequestLocalizationOptions ConfigureRequestLocalization(this RequestLocalizationOptions options)
{
options ??= new RequestLocalizationOptions();
var supportedCultures = Supported
.Select(culture => new CultureInfo(culture))
.ToList();
options.DefaultRequestCulture = new RequestCulture(Default, Default);
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
return options;
}
}
LocalizedPageRouteModelConvention.cs
public class LocalizedPageRouteModelConvention : IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
foreach (var selector in model.Selectors.ToList())
{
model.Selectors.Add(new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel
{
Order = -1,
Template = AttributeRouteModel
.CombineTemplates("{" + LanguageRouteConstraint.Key + "?}",
selector.AttributeRouteModel.Template),
}
});
}
}
}
The major problem of mine, is currently i dont have a working way to put a middleware between IUrlHelper and RazorPage link generator
Now, i can use as below:
var currentCulture = Request.RouteValues[LanguageRouteConstraint.Key];
Url.Page("/Index/Contact", new {culture= currentCulture});
but it doesn't make sense to trace codes in project from the scratch to change code from Url.Page("") to Url.Page("", new{culture=ANY})
As i said before, i checked many sites, but still i got nothing.
The only thing i need for now, is a WORKING WAY to use standard Url.Page(), that to be translated to Url.Page("", new{culture=ANY}) in background.
Thanks in advance
My problem is only about RazorPages, not Controllers

Related

Core 6.0 Web API Localization IStringLocalizer selecting incorrect culture

I'm developing a Web API project using Core 6.0 with localization.
I used a few online guides, primarily this one
I am using an external library for the resources.
Program.cs:
builder.Services.AddLocalization();
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.DefaultRequestCulture = new RequestCulture(culture: "en-US");
options.SupportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("he-IL"),
new CultureInfo("ru-RU")
};
options.RequestCultureProviders = new[] { new RouteDataRequestCultureProvider { IndexOfCulture = 3 } };
});
builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("lang", typeof(LanguageRouteConstraint));
});
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
IOptions<RequestLocalizationOptions>? localizationOptions = app.Services.GetService<IOptions<RequestLocalizationOptions>>();
if (localizationOptions != null)
app.UseRequestLocalization(localizationOptions.Value);
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
Extension methods:
public class LanguageRouteConstraint : IRouteConstraint
{
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
{
if (!values.ContainsKey("lang"))
return false;
string? culture = values["lang"]?.ToString();
return culture == "en-US" || culture == "he-IL" || culture == "ru-RU";
}
}
public class RouteDataRequestCultureProvider : RequestCultureProvider
{
public int IndexOfCulture;
public override Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext)
{
if (httpContext == null)
throw new ArgumentNullException(nameof(httpContext));
string? culture = httpContext.Request.Path.Value is not null && httpContext.Request.Path.Value.Split('/').Length > IndexOfCulture ? httpContext.Request.Path.Value?.Split('/')[IndexOfCulture]?.ToString() : null;
ProviderCultureResult? providerResultCulture = culture is null ? null : new(culture);
return Task.FromResult(providerResultCulture);
}
}
The controller:
[Route("[controller]/[action]/{lang:lang?}")]
[ApiController]
public class AccountController : BaseController
{
public AccountController(IStringLocalizer<LangRes.App> localizer) : base(localizer)
{
}
public async Task<IActionResult> Test()
{
return Ok(GetErrorMessage("TEST"));
}
}
And BaseController:
[Route("")]
[ApiController]
public class BaseController : ControllerBase
{
private readonly IStringLocalizer<LangRes.App> localizer;
public BaseController(IStringLocalizer<LangRes.App> localizer)
{
this.localizer = localizer;
}
public string GetErrorMessage(string result)
{
return localizer.GetString(result);
}
}
The external library LangRes has an empty App class and
App.en-US.resx with TEST key as This is english as value
App.he-IL.resx with TEST key as This is hebrew as value
App.ru-RU.resx with TEST key as This is russian as value
when options.DefaultRequestCulture = new RequestCulture(culture: "en-US") is used, both account/Test/he-IL and account/Test/ru-RU are not matched and the localizer uses the fallback en-US, so the response is This is english.
when options.DefaultRequestCulture = new RequestCulture(culture: "he-IL") is used:
account/Test/ru-RU is not matched and the localizer uses the fallback he-IL
However account/Test/en-US does match correctly and the result is This is english!
when options.DefaultRequestCulture = new RequestCulture(culture: "ru-RU") is used:
account/Test/he-IL is not matched and the localizer uses the fallback ru-RU
However account/Test/en-US does match correctly and the result is This is english!
Debugging shows that RouteDataRequestCultureProvider is returning the correct ProviderCultureResult culture for each request.
What am I missing here? It doesn't seem to be expected behavior.
Web API controllers dependency injection of IStringLocalizer depends on UiCulture, not Culture.
So this erratic behavior was caused since I did not define SupportedUICultures in the options of RequestLocalizationOptions.
Complete change in Program.cs:
List<CultureInfo> supportedCultures = new()
{
new CultureInfo("en-us"),
new CultureInfo("he-il"),
new CultureInfo("ru")
};
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.DefaultRequestCulture = new RequestCulture(culture: "ru");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures; // important bit
options.RequestCultureProviders = new[] { new RouteDataRequestCultureProvider { IndexOfCulture = 3 } };
});

Swagger - hide api version parameters

Is it possible to hide the 'api-version' and 'x-api-version' parameters?
services.AddApiVersioning(config =>
{
config.ReportApiVersions = true;
config.DefaultApiVersion = new ApiVersion(1, 0);
config.AssumeDefaultVersionWhenUnspecified = true;
config.ApiVersionReader = ApiVersionReader.Combine(
new QueryStringApiVersionReader(),
new HeaderApiVersionReader()
{
HeaderNames = { "x-api-version" }
});
});
services.AddVersionedApiExplorer(
options =>
{
// note: the specified format code will format the version as "'v'major[.minor][-status]"
options.GroupNameFormat = "'v'VVV";
options.DefaultApiVersionParameterDescription = "Do NOT modify api-version!";
});
I already checked how-to-set-up-swashbuckle-vs-microsoft-aspnetcore-mvc-versioning which implements a 'RemoveVersionFromParameter' method, but in that case the Swagger page would loose the api version and always uses the default v1.0. As shown in the code snippet, I am using the QueryStringApiVersionReader and HeaderApiVersionReader, but I don't want to support the url api versioning.
Note: The API does have multiple swagger json pages for all versions (e.g. V1, V1.1, V2.0)
You can try an operation filter. This is similar to Helder's solution, but the implementation doesn't have to be at the document level, so it seems simpler:
public void Configure(SwaggerGenOptions options)
{
// Filter out `api-version` parameters globally
options.OperationFilter<ApiVersionFilter>();
}
internal class ApiVersionFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var parametersToRemove = operation.Parameters.Where(x => x.Name == "api-version").ToList();
foreach (var parameter in parametersToRemove)
operation.Parameters.Remove(parameter);
}
}
Have you looked into IDocumentFilter with that you can remove stuff from the final swagger.json and that will remove it from the UI
Here is an example me removing some properties from the definitions:
private class HideStuffDocumentFilter : IDocumentFilter
{
public void Apply(SwaggerDocument swaggerDoc, SchemaRegistry s, IApiExplorer a)
{
foreach (var definition in swaggerDoc.definitions)
{
foreach (var prop in definition.Value.properties.ToList())
{
if (prop.Value.maxLength == 9999)
definition.Value.properties.Remove(prop);
}
}
}
}
I have a few more samples here:
https://github.com/heldersepu/Swagger-Net-Test/blob/e701b1d20d0b42c1287c3da2641ca521a0a7b592/Swagger_Test/App_Start/SwaggerConfig.cs#L766
You can add your own custom CSS
and use it to hide those elements (and do any other customisation you want).
app.UseSwaggerUI(c =>
{
...
c.InjectStylesheet("/swagger-ui/custom.css");
...
});
Edit - example:
Suppose you're trying to hide - in my example; you can easily adapt it to yours - the tenantId parameter in this "Remove Basket" operation:
This would do that:
div#operations-baskets-remove tr[data-param-name="tenantId"] {
display: none;
}
This can be done by setting ApiExplorerOption SubstituteApiVersionInUrl = true . In your case:
services.AddVersionedApiExplorer(
options =>
{
// note: the specified format code will format the version as "'v'major[.minor][-status]"
options.GroupNameFormat = "'v'VVV";
options.DefaultApiVersionParameterDescription = "Do NOT modify api-version!";
options.SubstituteApiVersionInUrl = true;
});
this worked for me.
public class SwaggerOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (operation?.Parameters == null
|| !operation.Parameters.Any())
{
return;
}
var parametersWithPropertiesToIgnore = context.ApiDescription
.ActionDescriptor.Parameters.Where(p =>
p.ParameterType.GetProperties()
.Any(t => t.GetCustomAttribute<IgnoreDataMemberAttribute>() != null));
foreach (var parameter in parametersWithPropertiesToIgnore)
{
var ignoreDataMemberProperties = parameter.ParameterType.GetProperties()
.Where(t => t.GetCustomAttribute<IgnoreDataMemberAttribute>() != null)
.Select(p => p.Name);
operation.Parameters = operation.Parameters.Where(p => !ignoreDataMemberProperties.Contains(p.Name))
.ToList();
}
}
}
On Startup
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Api", Version = "v1" });
c.SchemaFilter<SwaggerSchemaFilter>();
c.OperationFilter<SwaggerOperationFilter>();
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var filePath = Path.Combine(System.AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(filePath);
});
Just add the data annotation [IgnoreDataMember] in the property to hide it.
public class ExampleRequest
{
[IgnoreDataMember]
public string? HiddenProp { get; set; }
public string OtherProp { get; set; }
}
I have based on this article to create this solution.
Do you really want to hide the API version parameters or just make them read-only? The API version parameter is required when invoking the API so that should not be hidden from the documentation or user.
Your question implies that you're more concerned that the user not be able to change the API version default value for a particular documented API. That's fair, but has a different solution.
While you can mark a parameter as read-only, I have not been able to get the UI to honor that. It might be a bug or something else is misconfigured. You can definitely get read-only like behavior by using an enumeration with a single value. For example:
public class SwaggerDefaultValues : IOperationFilter
{
public void Apply( OpenApiOperation operation, OperationFilterContext context )
{
var apiDescription = context.ApiDescription;
operation.Deprecated |= apiDescription.IsDeprecated();
if ( operation.Parameters == null )
{
return;
}
foreach ( var parameter in operation.Parameters )
{
var description = apiDescription.ParameterDescriptions
.First( p => p.Name == parameter.Name );
parameter.Description ??= description.ModelMetadata?.Description;
if ( parameter.Schema.Default == null &&
description.DefaultValue != null &&
description.DefaultValue is not DBNull &&
description.ModelMetadata is ModelMetadata modelMetadata )
{
var json = JsonSerializer.Serialize(
description.DefaultValue,
modelMetadata.ModelType );
// this will set the API version, while also making it read-only
parameter.Schema.Enum = new[] { OpenApiAnyFactory.CreateFromJson( json ) };
}
parameter.Required |= description.IsRequired;
}
}
You can added startup.cs file.
services.AddApiVersioning(options =>
{
// reporting api versions will return the headers "api-supported-versions" and "api-deprecated-versions"
options.ReportApiVersions = true;
});
services.AddVersionedApiExplorer(options =>
{
// add the versioned api explorer, which also adds IApiVersionDescriptionProvider service
// note: the specified format code will format the version as "'v'major[.minor][-status]"
options.GroupNameFormat = "'v'VVV";
// note: this option is only necessary when versioning by url segment. the SubstitutionFormat
// can also be used to control the format of the API version in route templates
options.SubstituteApiVersionInUrl = true;
});
And then,you can added top on the controller. But I try to without this([ApiVersion("1.0")]),it could run.I had successfully hide version parametre.
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/Check")]
[ApiController]
[Authorize]
public class CheckController : ControllerBase {}

Redirecting responsibility from short to full URL in another controller

As a homework I have to do a simple URL shortener, where I can add full link to list, which is processed by Hashids.net library, and I get short version of an URL.
I've got something like this now, but I got stuck on redirecting it back to full link.
I would like to add a new controller, which will take the responsibility of redirecting short URL to full URL. After clicking short URL it should go to localhost:xxxx/ShortenedUrl and then redirect to full link. Any tips how can I create this?
I was trying to do it by #Html.ActionLink(#item.ShortenedLink, "Index", "Redirect") and return Redirect(fullLink) in Redirect controller but it didn't work as I expect.
And one more question about routes, how can I achieve that after clicking short URL it will give me localhost:XXXX/ShortenedURL (i.e. localhost:XXXX/FSIAOFJO2#). Now I've got
#Html.DisplayFor(model => item.ShortenedLink)
and
app.UseMvc(routes =>
{
routes.MapRoute("default", "{controller=Link}/{action=Index}");
});
but it gives me localhost:XXXX/Link/ShortenedURL, so I would like to omit this Link in URL.
View (part with Short URL):
<td>#Html.ActionLink(item.ShortenedLink,"GoToFull","Redirect", new { target = "_blank" }))</td>
Link controller:
public class LinkController : Controller
{
private ILinksRepository _repository;
public LinkController(ILinksRepository linksRepository)
{
_repository = linksRepository;
}
[HttpGet]
public IActionResult Index()
{
var links = _repository.GetLinks();
return View(links);
}
[HttpPost]
public IActionResult Create(Link link)
{
_repository.AddLink(link);
return Redirect("Index");
}
[HttpGet]
public IActionResult Delete(Link link)
{
_repository.DeleteLink(link);
return Redirect("Index");
}
}
Redirect controller which I am trying to do:
private ILinksRepository _repository;
public RedirectController(ILinksRepository linksRepository)
{
_repository = linksRepository;
}
public IActionResult GoToFull()
{
var links = _repository.GetLinks();
return Redirect(links[0].FullLink);
}
Is there a better way to get access to links list in Redirect Controller?
This is my suggestion, trigger the link via AJAX, here is working example:
This is the HTML element binded through model:
#Html.ActionLink(Model.ShortenedLink, "", "", null,
new { onclick = "fncTrigger('" + "http://www.google.com" + "');" })
This is the javascript ajax code:
function fncTrigger(id) {
$.ajax({
url: '#Url.Action("TestDirect", "Home")',
type: "GET",
data: { id: id },
success: function (e) {
},
error: function (err) {
alert(err);
},
});
}
Then on your controller to receive the ajax click:
public ActionResult TestDirect(string id)
{
return JavaScript("window.location = '" + id + "'");
}
Basically what I am doing here is that, after I click the link, it will call the TestDirect action, then redirect it to using the passed url parameter. You can do the conversion inside this action.
To create dynamic data-driven URLs, you need to create a custom IRouter. Here is how it can be done:
CachedRoute<TPrimaryKey>
This is a reusable generic class that maps a set of dynamically provided URLs to a single action method. You can inject an ICachedRouteDataProvider<TPrimaryKey> to provide the data (a URL to primary key mapping).
The data is cached to prevent multiple simultaneous requests from overloading the database (routes run on every request). The default cache time is for 15 minutes, but you can adjust as necessary for your requirements.
If you want it to act "immediate", you could build a more advanced cache that is updated just after a successful database update of one of the records. That is, the same action method would update both the database and the cache.
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
public class CachedRoute<TPrimaryKey> : IRouter
{
private readonly string _controller;
private readonly string _action;
private readonly ICachedRouteDataProvider<TPrimaryKey> _dataProvider;
private readonly IMemoryCache _cache;
private readonly IRouter _target;
private readonly string _cacheKey;
private object _lock = new object();
public CachedRoute(
string controller,
string action,
ICachedRouteDataProvider<TPrimaryKey> dataProvider,
IMemoryCache cache,
IRouter target)
{
if (string.IsNullOrWhiteSpace(controller))
throw new ArgumentNullException("controller");
if (string.IsNullOrWhiteSpace(action))
throw new ArgumentNullException("action");
if (dataProvider == null)
throw new ArgumentNullException("dataProvider");
if (cache == null)
throw new ArgumentNullException("cache");
if (target == null)
throw new ArgumentNullException("target");
_controller = controller;
_action = action;
_dataProvider = dataProvider;
_cache = cache;
_target = target;
// Set Defaults
CacheTimeoutInSeconds = 900;
_cacheKey = "__" + this.GetType().Name + "_GetPageList_" + _controller + "_" + _action;
}
public int CacheTimeoutInSeconds { get; set; }
public async Task RouteAsync(RouteContext context)
{
var requestPath = context.HttpContext.Request.Path.Value;
if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
{
// Trim the leading slash
requestPath = requestPath.Substring(1);
}
// Get the page id that matches.
TPrimaryKey id;
//If this returns false, that means the URI did not match
if (!GetPageList().TryGetValue(requestPath, out id))
{
return;
}
//Invoke MVC controller/action
var routeData = context.RouteData;
// TODO: You might want to use the page object (from the database) to
// get both the controller and action, and possibly even an area.
// Alternatively, you could create a route for each table and hard-code
// this information.
routeData.Values["controller"] = _controller;
routeData.Values["action"] = _action;
// This will be the primary key of the database row.
// It might be an integer or a GUID.
routeData.Values["id"] = id;
await _target.RouteAsync(context);
}
public VirtualPathData GetVirtualPath(VirtualPathContext context)
{
VirtualPathData result = null;
string virtualPath;
if (TryFindMatch(GetPageList(), context.Values, out virtualPath))
{
result = new VirtualPathData(this, virtualPath);
}
return result;
}
private bool TryFindMatch(IDictionary<string, TPrimaryKey> pages, IDictionary<string, object> values, out string virtualPath)
{
virtualPath = string.Empty;
TPrimaryKey id;
object idObj;
object controller;
object action;
if (!values.TryGetValue("id", out idObj))
{
return false;
}
id = SafeConvert<TPrimaryKey>(idObj);
values.TryGetValue("controller", out controller);
values.TryGetValue("action", out action);
// The logic here should be the inverse of the logic in
// RouteAsync(). So, we match the same controller, action, and id.
// If we had additional route values there, we would take them all
// into consideration during this step.
if (action.Equals(_action) && controller.Equals(_controller))
{
// The 'OrDefault' case returns the default value of the type you're
// iterating over. For value types, it will be a new instance of that type.
// Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct),
// the 'OrDefault' case will not result in a null-reference exception.
// Since TKey here is string, the .Key of that new instance will be null.
virtualPath = pages.FirstOrDefault(x => x.Value.Equals(id)).Key;
if (!string.IsNullOrEmpty(virtualPath))
{
return true;
}
}
return false;
}
private IDictionary<string, TPrimaryKey> GetPageList()
{
IDictionary<string, TPrimaryKey> pages;
if (!_cache.TryGetValue(_cacheKey, out pages))
{
// Only allow one thread to poplate the data
lock (_lock)
{
if (!_cache.TryGetValue(_cacheKey, out pages))
{
pages = _dataProvider.GetPageToIdMap();
_cache.Set(_cacheKey, pages,
new MemoryCacheEntryOptions()
{
Priority = CacheItemPriority.NeverRemove,
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(this.CacheTimeoutInSeconds)
});
}
}
}
return pages;
}
private static T SafeConvert<T>(object obj)
{
if (typeof(T).Equals(typeof(Guid)))
{
if (obj.GetType() == typeof(string))
{
return (T)(object)new Guid(obj.ToString());
}
return (T)(object)Guid.Empty;
}
return (T)Convert.ChangeType(obj, typeof(T));
}
}
LinkCachedRouteDataProvider
Here we have a simple service that retrieves the data from the database and loads it into a Dictionary. The most complicated part is the scope that needs to be setup in order to use DbContext from within the service.
public interface ICachedRouteDataProvider<TPrimaryKey>
{
IDictionary<string, TPrimaryKey> GetPageToIdMap();
}
public class LinkCachedRouteDataProvider : ICachedRouteDataProvider<int>
{
private readonly IServiceProvider serviceProvider;
public LinkCachedRouteDataProvider(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider
?? throw new ArgumentNullException(nameof(serviceProvider));
}
public IDictionary<string, int> GetPageToIdMap()
{
using (var scope = serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetService<ApplicationDbContext>();
return (from link in dbContext.Links
select new KeyValuePair<string, int>(
link.ShortenedLink.Trim('/'),
link.Id)
).ToDictionary(pair => pair.Key, pair => pair.Value);
}
}
}
RedirectController
Our redirect controller accepts the primary key as an id parameter and then looks up the database record to get the URL to redirect to.
public class RedirectController
{
private readonly ApplicationDbContext dbContext;
public RedirectController(ApplicationDbContext dbContext)
{
this.dbContext = dbContext
?? throw new ArgumentNullException(nameof(dbContext));
}
public IActionResult GoToFull(int id)
{
var link = dbContext.Links.FirstOrDefault(x => x.Id == id);
return new RedirectResult(link.FullLink);
}
}
In a production scenario, you would probably want to make this a permanent redirect return new RedirectResult(link.FullLink, true), but those are automatically cached by browsers which makes testing difficult.
Startup.cs
We setup the DbContext, the memory cache, and the LinkCachedRouteDataProvider in our DI container for use later.
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddMvc();
services.AddMemoryCache();
services.AddSingleton<LinkCachedRouteDataProvider>();
}
And then we setup our routing using the CachedRoute<TPrimaryKey>, providing all dependencies.
app.UseMvc(routes =>
{
routes.Routes.Add(new CachedRoute<int>(
controller: "Redirect",
action: "GoToFull",
dataProvider: app.ApplicationServices.GetService<LinkCachedRouteDataProvider>(),
cache: app.ApplicationServices.GetService<IMemoryCache>(),
target: routes.DefaultHandler)
// Set to 60 seconds of caching to make DB updates refresh quicker
{ CacheTimeoutInSeconds = 60 });
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
To build these short URLs on the user interface, you can use tag helpers (or HTML helpers) the same way you would with any other route:
<a asp-area="" asp-controller="Redirect" asp-action="GoToFull" asp-route-id="1">
#Url.Action("GoToFull", "Redirect", new { id = 1 })
</a>
Which is generated as:
/M81J1w0A
You can of course use a model to pass the id parameter into your view when it is generated.
<a asp-area="" asp-controller="Redirect" asp-action="GoToFull" asp-route-id="#Model.Id">
#Url.Action("GoToFull", "Redirect", new { id = Model.Id })
</a>
I have made a Demo on GitHub. If you enter the short URLs into the browser, they will be redirected to the long URLs.
M81J1w0A -> https://maps.google.com/
r33NW8K -> https://stackoverflow.com/
I didn't create any of the views to update the URLs in the database, but that type of thing is covered in several tutorials such as Get started with ASP.NET Core MVC and Entity Framework Core using Visual Studio, and it doesn't look like you are having issues with that part.
References:
Get started with ASP.NET Core MVC and Entity Framework Core using Visual Studio
Change route collection of MVC6 after startup
MVC Routing template to represent infinite self-referential hierarchical category structure
Imlementing a Custom IRouter in ASP.NET 5 (vNext) MVC 6

ASP.NET RouteValueDictionary contains Entry with Key "RouteModels"

While implementing a WebApplication we came across an dubious entry in the RouteValueDictionary with the key "RouteModels".
Even Google doesn't give me results for that entry.
Can someone explain where it comes from?
Does it come from the custom routes?
Or are this the values bound by modelbinding?
The declaration of the ActionMethode at the controller is:
[Route("User/{user}")]
public ActionResult UserForums(User user, int page = 1)
The RouteValueDictionary of the Request contains the following entries:
Does it come from the custom routes?
Unclear. However, route values are completely dependent on how a custom route class is designed.
public class SampleRoute : RouteBase
{
public override RouteData GetRouteData(HttpContextBase httpContext)
{
var path = httpContext.Request.Path.Substring(1);
if (path.Equals("the/virtual/path"))
{
var routeData = new RouteData(this, new MvcRouteHandler());
routeData.Values["user"] = "TheUser";
routeData.Values["controller"] = "Home";
routeData.Values["action"] = "Custom";
routeData.Values["RouteModels"] = new List<RouteModel> { new RouteModel { Name = "Foo" }, new RouteModel { Name = "Bar" } };
routeData.DataTokens["MetaData"] = "SomeMetadata";
return routeData;
}
return null;
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
string user = values["user"] as string;
string controller = values["controller"] as string;
string action = values["action"] as string;
IEnumerable<RouteModel> routeModels = values["RouteModels"] as IEnumerable<RouteModel>;
if ("TheUser".Equals(user) && "Home".Equals(controller) && "Custom".Equals(action))
{
// Use the route models to either complete the match or to do something else.
return new VirtualPathData(this, "the/virtual/path");
}
return null;
}
private class RouteModel
{
public string Name { get; set; }
}
}
My guess is that this is either some specialized route or filter that is storing extra data in the route collection. But why the author chose to store it as route values rather than DataTokens (for metadata) is a mystery.

Creating per-request controller/action based formatters in ASP.NET 5

I'm trying to implement HATEOAS in my ASP rest API, changing the ReferenceResolverProvider.
The problem is, that depending on which controller I use, I'd like to use different ReferenceResolvers, because I need to behave differently for each Controller.
Now I have universal options:
services.AddMvc()
.AddJsonOptions(option => option.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver())
.AddJsonOptions(options => options.SerializerSettings.ReferenceResolverProvider = () => new RoomsReferenceResolver<Room>())
.AddJsonOptions(options => options.SerializerSettings.PreserveReferencesHandling = PreserveReferencesHandling.Objects);
And I want to have something like this:
services.AddMvc()
.AddJsonOptions(option => option.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver())
.AddJsonOptions<RoomsController>(options => options.SerializerSettings.ReferenceResolverProvider = () => new RoomsReferenceResolver<Room>())
.AddJsonOptions(options => options.SerializerSettings.PreserveReferencesHandling = PreserveReferencesHandling.Objects);
You seem to be wanting to create a per-controller specific formatters. This can be achieved by using a filter called IResourceFilter. A quick example:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class CamelCaseJsonFormatterResourceFilter : Attribute, IResourceFilter
{
private readonly JsonSerializerSettings serializerSettings;
public CamelCaseJsonFormatterResourceFilter()
{
// Since the contract resolver creates the json contract for the types it needs to deserialize/serialize,
// cache it as its expensive
serializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
}
public void OnResourceExecuting(ResourceExecutingContext context)
{
// remove existing input formatter and add a new one
var camelcaseInputFormatter = new JsonInputFormatter(serializerSettings);
var inputFormatter = context.InputFormatters.FirstOrDefault(frmtr => frmtr is JsonInputFormatter);
if (inputFormatter != null)
{
context.InputFormatters.Remove(inputFormatter);
}
context.InputFormatters.Add(camelcaseInputFormatter);
// remove existing output formatter and add a new one
var camelcaseOutputFormatter = new JsonOutputFormatter(serializerSettings);
var outputFormatter = context.OutputFormatters.FirstOrDefault(frmtr => frmtr is JsonOutputFormatter);
if (outputFormatter != null)
{
context.OutputFormatters.Remove(outputFormatter);
}
context.OutputFormatters.Add(camelcaseOutputFormatter);
}
}
// Here I am using the filter to indicate that only the Index action should give back a camelCamse response
public class HomeController : Controller
{
[CamelCaseJsonFormatterResourceFilter]
public Person Index()
{
return new Person() { Id = 10, AddressInfo = "asdfsadfads" };
}
public Person Blah()
{
return new Person() { Id = 10, AddressInfo = "asdfsadfads" };
}
If you are curious about the filter execution order, following is an example of the sequence of them:
Inside TestAuthorizationFilter.OnAuthorization
Inside TestResourceFilter.OnResourceExecuting
Inside TestActionFilter.OnActionExecuting
Inside Home.Index
Inside TestActionFilter.OnActionExecuted
Inside TestResultFilter.OnResultExecuting
Inside TestResultFilter.OnResultExecuted
Inside TestResourceFilter.OnResourceExecuted
Interesting problem.
What about making the ReferenceResolver a facade:
class ControllerReferenceResolverFacade : IReferenceResolver
{
private IHttpContextAccessor _context;
public ControllerReferenceResolverFacade(IHttpContextAccessor context)
{
_context = context;
}
public void AddReference(object context, string reference, object value)
{
if ((string)_context.HttpContext.RequestServices.GetService<ActionContext>().RouteData.Values["Controller"] == "HomeController")
{
// pass off to HomeReferenceResolver
}
throw new NotImplementedException();
}
Then you should be able to do:
services.AddMvc()
.AddJsonOptions(options => options.SerializerSettings.ReferenceResolverProvider = () => {
return new ControllerReferenceResolverFacade(
services.BuildServiceProvider().GetService<IHttpContextAccessor>());
});
This might not be exactly what you need but it might help you get started?

Categories

Resources