The OnTopic.AspNetCore.Mvc
assembly provides a default implementation for utilizing OnTopic with ASP.NET Core (3.0 and above) . It is the recommended client for working with OnTopic.
There are six components at the heart of the ASP.NET Core implementation.
TopicController
: This is a default controller instance that can be used for any topic path. It will automatically validate that theTopic
exists, that it is not disabled (!IsDisabled
), and will honor any redirects (e.g., if theUrl
attribute is filled out). Otherwise, it will return aTopicViewResult
based on a view model, view name, and content type.TopicRouteValueTransformer
: ADynamicRouteValueTransformer
for use with the ASP.NET Core'sMapDynamicControllerRoute()
method, allowing for route parameters to be implicitly inferred; notably, it will use thearea
as the defaultcontroller
androotTopic
, if those route parameters are not otherwise defined.TopicViewLocationExpander
: Assists the out-of-the-box Razor view engine in locating views associated with OnTopic, e.g. by looking in~/Views/ContentTypes/{ContentType}.cshtml
, or~/Views/{ContentType}/{View}.cshtml
. See View Locations below.TopicViewResultExecutor
: When theTopicController
returns aTopicViewResult
, theTopicViewResultExecutor
takes over and attempts to identify the correct view based on theaccept
headers,?view=
query string parameter, topic's defaultView
attribute and, finally, the topic'sContentType
attribute. See View Matching below.ServiceCollectionExtensions
: A set of extensions to be used in an ASP.NET Core website'sStartup
class that automatically handle registering services, controllers, and other extensions fromOnTopic.AspNetCore.Mvc
.ITopicRepositoryExtensions
: A set of extensions that allows loading topics based on an ASP.NET CoreRouteData
collection, includingOnTopic
route variables, such aspath
andcontenttype
.
There are five main controllers and view components that ship with the ASP.NET Core implementation. In addition to the core TopicController
, these include the following ancillary classes:
ErrorController
: Provides a specializedTopicController
with anHttp()
action for handling status code errors (e.g., fromUseStatusCodePages()
).RedirectController
: Provides a singleRedirect
action which can be bound to a route such as/Topic/{ID}/
; this provides support for permanent URLs that are independent of theGetWebPath()
.SitemapController
: Provides a singleSitemap
action which recurses over the entire Topic graph, including all attributes, and returns an XML document with a sitemaps.org schema.MenuViewComponentBase<T>
: Provides support for a navigation menu by automatically mapping the top three tiers of the current namespace (e.g.,Web
, its children, and grandchildren). Can accept anyINavigationTopicViewModel
as a generic argument; that will be used as the view model for each mapped instance.PageLevelNavigationViewComponentBase<T>
: Provides support for page-level navigation by automatically mapping the child topics from the nearestPageGroup
. Can accept anyINavigationTopicViewModel
as a generic argument; that will be used as the view model for each mapped instance.
Note: There is no practical way for ASP.NET Core to provide routing for generic controllers and view components. As such, these must be subclassed by each implementation. The derived class needn't do anything outside of provide a specific type reference to the generic base. For example:
public class MenuViewComponent: MenuViewComponentBase<NavigationTopicViewModel> { public MenuViewComponent( ITopicRepository topicRepository, IHierarchicalTopicMappingService<NavigationTopicViewModel> hierarchicalTopicMappingService ): base(topicRepository, hierarchicalTopicMappingService) {} }
There are two filters included with the ASP.NET Core implementation, which are meant to work in conjunction with TopicController
:
[ValidateTopic]
: A filter attribute that handles topics that aren't intended to be served publicly, such asPageGroup
andContainer
content types, or topics withUrl
orIsDisabled
set.[TopicResponseCache]
: A filter attribute registered onTopicController
which checks for an affiliatedCacheProfile
topic and sets HTTP response headers accordingly. Compatible with the ASP.NET Core Response Caching Middleware.
By default, OnTopic matches views based on the current topic's ContentType
and, if available, View
.
There are multiple ways for a view to be set. The TopicViewResultExecutor
will automatically evaluate views based on the following locations. The first one to match a valid view name is selected.
?View=
query string parameter (e.g.,?View=Accordion
)Accept
headers (e.g.,Accept=application/json
); will treat the segment after the/
as a possible view nameAction
name (e.g.,Index()
orJsonAsync()
); will exclude theAsync
suffixView
attribute (i.e.,topic.View
)ContentType
attribute (i.e.,topic.ContentType
)
This allows multiple views to be available for any individual content type, thus allowing pages using the same content type to potentially be rendered with different layouts or, even, different content types (e.g., JSON vs. HTML).
For each of the above View Matching rules, the TopicViewLocationExpander
will search the following locations for a matching view:
~/Views/{Controller}/{View}.cshtml
~/Views/{ContentType}/{View}.cshtml
~/Views/{ContentType}/Shared/{View}.cshtml
~/Views/ContentTypes/{ContentType}.{View}.cshtml
~/Views/{Controller}/Shared/{View}.cshtml
~/Views/ContentTypes/Shared/{View}.cshtml
~/Views/ContentTypes/{View}.cshtml
~/Views/Shared/{View}.cshtml
Note: After searching each of these locations for each of the View Matching rules, control will be handed over to the
RazorViewEngine
, which will search the out-of-the-box default locations for ASP.NET Core.
If the topic.ContentType
is ContentList
and the Accept
header is application/json
then the TopicViewResult
and TopicViewEngine
would coordinate to search the following paths:
~/Views/Topic/JSON.cshtml
~/Views/ContentList/JSON.cshtml
~/Views/ContentList/Shared/JSON.cshtml
~/Views/ContentTypes/ContentList.JSON.cshtml
~/Views/Topic/Shared/JSON.cshtml
~/Views/ContentTypes/Shared/JSON.cshtml
~/Views/ContentTypes/JSON.cshtml
~/Views/Shared/JSON.cshtml
If no match is found, then the next Accept
header will be searched. Eventually, if no match can be found on the various View Matching rules, then the following will be searched:
~/Views/Topic/ContentList.cshtml
~/Views/ContentList/ContentList.cshtml
~/Views/ContentList/Shared/ContentList.cshtml
~/Views/ContentTypes/ContentList.ContentList.cshtml
~/Views/Topic/Shared/ContentList.cshtml
~/Views/ContentTypes/Shared/ContentList.cshtml
~/Views/ContentTypes/ContentList.cshtml
Installation can be performed by providing a <PackageReference /
> to the OnTopic.AspNetCore.Mvc
NuGet package.
<Project Sdk="Microsoft.NET.Sdk.Web">
…
<ItemGroup>
<PackageReference Include="OnTopic.AspNetCore.Mvc" Version="5.0.0" />
</ItemGroup>
</Project>
In the Startup
class, OnTopic's ASP.NET Core support can be registered by calling the AddTopicSupport()
extension method:
public class Startup {
public void ConfigureServices(IServiceCollection services) {
services.AddMvc().AddTopicSupport();
}
}
Note: This will register the
TopicViewLocationExpander
,TopicViewResultExecutor
,TopicRouteValueTransformer
, as well as all Controllers that ship withOnTopic.AspNetCore.Mvc
.
Note: When using ASP.NET Core 6's minimal hosting model, this will instead be placed in the
Program
class as a top-level statement.
In addition, within the same ConfigureServices()
method, you will need to establish a class that implements IControllerActivator
and IViewComponentActivator
, and will represent the site's Composition Root for dependency injection. This will typically look like:
var activator = new OrganizationNameActivator(Configuration.GetConnectionString("OnTopic"))
services.AddSingleton<IControllerActivator>(activator);
services.AddSingleton<IViewComponentActivator>(activator);
See Composition Root below for information on creating an implementation of IControllerActivator
and IViewComponentActivator
.
Note: The controller activator name is arbitrary, and should follow the conventions appropriate for the site. Ignia typically uses
{OrganizationName}Activator
(e.g.,IgniaActivator
), but OnTopic doesn't need to know or care what the name is; that is between your application and ASP.NET Core.
Note: The connection string can come from any source, or even be hard-coded. Ignia recommends storing it as part of your
secrets.json
during development and as part of the hosting environment's application settings. The above code, for instance, will work with a localsecrets.json
and Azure's connection configuration.
When registering routes via Startup.Configure()
you may register any routes for OnTopic using the extension method:
public class Startup {
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
app.UseEndpoints(endpoints => {
endpoints.MapTopicAreaRoute(); // {area:exists}/{**path}
endpoints.MapImplicitAreaControllerRoute(); // {area:exists}/{action=Index}
endpoints.MapDefaultControllerRoute(); // {controller=Home}/{action=Index}/{id?}
endpoints.MapDefaultAreaControllerRoute(); // {area:exists}/{controller}/{action=Index}/{id?}
endpoints.MapTopicErrors(); // Error/{errorCode}
endpoints.MapTopicRoute("Web"); // Web/{**path}
endpoints.MapTopicRedirect(); // Topic/{topicId}
endpoints.MapControllers();
});
}
}
Note: Because OnTopic relies on wildcard path names, a new route should be configured for every root namespace (e.g.,
/Web
). While it's possible to configure OnTopic to evaluate all paths, this makes it difficult to delegate control to other controllers and handlers, when necessary. As a result, it is recommended that each root container be registered individually.
Note: When using ASP.NET Core 6's minimal hosting model, these will instead be placed in the
Program
class as a top-level statement.
As OnTopic relies on constructor injection, the application must be configured in a Composition Root—in the case of ASP.NET Core, that means a custom controller activator for controllers, and view component activator for view components. For controllers, the basic structure of this might look like:
var sqlTopicRepository = new SqlTopicRepository(connectionString);
var cachedTopicRepository = new CachedTopicRepository(sqlTopicRepository);
var topicViewModelLookupService = new TopicViewModelLookupService();
var topicMappingService = new TopicMappingService(cachedTopicRepository, topicViewModelLookupService);
return controllerType.Name switch {
nameof(TopicController) => new TopicController(_topicRepository, _topicMappingService),
nameof(RedirectController) => new RedirectController(_topicRepository),
nameof(SitemapController) => new SitemapController(_topicRepository),
_ => throw new InvalidOperationException($"Unknown controller {controllerType.Name}")
};
For a complete reference template, including the ancillary controllers, view components, and a more maintainable structure, see the OrganizationNameActivator.cs
Gist. Optionally, you may use a dependency injection container.
Note: The default
TopicController
will automatically identify the current topic (based on theRouteData
), map the current topic to a corresponding view model (based on theTopicMappingService
conventions), and then return a corresponding view (based on the view conventions). For most applications, this is enough. If custom mapping rules or additional presentation logic are needed, however, implementors can subclassTopicController
.
The ErrorController
provides support for handling ASP.NET Core's UseStatusCodePages()
middleware, while continuing to support a range of other options. Routing to the controller can be supported by any of the following options, in isolation or together:
public class Startup {
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
app.UseEndpoints(endpoints => {
endpoints.MapTopicErrors(); // Error/{errorCode}
endpoints.MapTopicErrors("Errors", false); // Errors/{errorCode}; disables includeStaticFiles
endpoints.MapDefaultControllerRoute(); // Error/Http/{errorCode}
endpoints.MapTopicRoute("Error"); // Error/{path}; e.g., Error/Unauthorized
}
}
}
Note: When using ASP.NET Core 6's minimal hosting model, these will instead be placed in the
Program
class as a top-level statement.
The first three of these options all use the Http()
action, which will provide the following fallback logic:
- If
Error:{errorCode}
exists, use that (e.g.,Error:404
) - If
Error:{errorCode/100*100} exists, use that (e.g.,
Error:400`) - If
Error
exists, use that (e.g.,Error
)
These are all intended to be used with one of ASP.NET Core's UseStatusCodePages()
methods. For instance:
app.UseStatusCodePagesWithReExecute("/Error/{0}");
The last option allows the same ErrorController
to be used with any other custom error handling that might be configured—such as middleware, or the legacy <httpErrors />
handler—to handle any custom page under the Error
topic.