Skip to content

Commit 3e184d4

Browse files
committed
Merge branch 'feature/ErrorController' into develop
In OnTopic 4, we had an `ErrorController`, but it didn't integrate intelligently with either ASP.NET Core nor OnTopic, and was removed as part of OnTopic 5. The new `ErrorController` mitigates these limitations. It includes an intelligent `HttpAsync()` action which accepts a `statusCode`, and will attempt to identify the best match for it from available topics. E.g., if the `statusCode` is 404, it will first look for a topic with the `Key` of 404, then 400, and finally return the root `Error` topic. This is intended, specifically, to integrate with ASP.NET Core's `UseStatusCodePages()`—or, preferably, `UseStatusCodePagesWithReExecute()`, which will inject the `statusCode` into the configured path (via the `{0}` placeholder) The new `ErrorController` can be routed to through a variety of mechanisms. These include the standard `MapDefaultControllerRoute()` (via `/Error/Http/{statusCode}`), `MapTopicRoute()` (via `/Error/CustomPageName` for custom topic pages), and the new `MapTopicErrors()` extension method (via `/Error/{statusCode}`). This includes updates to the documentation, the unit tests, and the integration tests, and also integrates the new functionality into the `Host` project. This concludes the implementation of Issue #91.
2 parents 79e440f + 4819c7e commit 3e184d4

15 files changed

Lines changed: 577 additions & 277 deletions

File tree

OnTopic.AspNetCore.Mvc.Host/Program.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,9 @@
4848
/*------------------------------------------------------------------------------------------------------------------------------
4949
| Configure: Error Pages
5050
\-----------------------------------------------------------------------------------------------------------------------------*/
51-
if (app.Environment.IsDevelopment()) {
52-
app.UseDeveloperExceptionPage();
53-
}
54-
else {
55-
app.UseExceptionHandler("/Home/Error");
51+
if (!app.Environment.IsDevelopment()) {
52+
app.UseStatusCodePagesWithReExecute("/Error/{0}");
53+
app.UseExceptionHandler("/Error/500/");
5654
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
5755
app.UseHsts();
5856
}
@@ -69,9 +67,16 @@
6967
/*------------------------------------------------------------------------------------------------------------------------------
7068
| Configure: MVC
7169
\-----------------------------------------------------------------------------------------------------------------------------*/
72-
app.MapTopicRoute("Web");
73-
app.MapTopicSitemap();
74-
app.MapTopicRedirect();
70+
app.MapImplicitAreaControllerRoute(); // {area:exists}/{action=Index}
71+
app.MapDefaultAreaControllerRoute(); // {area:exists}/{controller}/{action=Index}/{id?}
72+
app.MapTopicAreaRoute(); // {area:exists}/{**path}
73+
74+
app.MapTopicErrors(rootTopic: "Error"); // Error/{statusCode}
75+
app.MapDefaultControllerRoute(); // {controller=Home}/{action=Index}/{id?}
76+
app.MapTopicRoute(rootTopic: "Web"); // Web/{**path}
77+
app.MapTopicRoute(rootTopic: "Error"); // Error/{**path}
78+
app.MapTopicSitemap(); // Sitemap
79+
app.MapTopicRedirect(); // Topic/{topicId}
7580
app.MapControllers();
7681

7782
/*------------------------------------------------------------------------------------------------------------------------------

OnTopic.AspNetCore.Mvc.Host/SampleActivator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ public object Create(ControllerContext context) {
117117
return type.Name switch {
118118
nameof(TopicController) =>
119119
new TopicController(_topicRepository, _topicMappingService),
120+
nameof(ErrorController) =>
121+
new ErrorController(_topicRepository, _topicMappingService),
120122
nameof(SitemapController) =>
121123
new SitemapController(_topicRepository),
122124
nameof(RedirectController) =>

OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Repositories/StubTopicRepository.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ private static Topic CreateFakeData() {
148148
var web = new Topic("Web", "Page", rootTopic, currentAttributeId++);
149149
_ = new Topic("ContentList", "ContentList", web, currentAttributeId++);
150150
_ = new Topic("MissingView", "Missing", web, currentAttributeId++);
151+
_ = new Topic("Container", "Container", web, currentAttributeId++);
151152

152153
/*------------------------------------------------------------------------------------------------------------------------
153154
| Establish area topics
@@ -161,6 +162,14 @@ private static Topic CreateFakeData() {
161162
View = "Accordion"
162163
};
163164

165+
/*------------------------------------------------------------------------------------------------------------------------
166+
| Establish error topics
167+
\-----------------------------------------------------------------------------------------------------------------------*/
168+
var error = new Topic("Error", "Page", rootTopic, currentAttributeId++);
169+
170+
_ = new Topic("400", "Page", error, currentAttributeId++);
171+
_ = new Topic("Unauthorized", "Page", error, currentAttributeId++);
172+
164173
/*------------------------------------------------------------------------------------------------------------------------
165174
| Set to cache
166175
\-----------------------------------------------------------------------------------------------------------------------*/

OnTopic.AspNetCore.Mvc.IntegrationTests.Host/SampleActivator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ public object Create(ControllerContext context) {
106106
new TopicController(_topicRepository, _topicMappingService),
107107
nameof(AreaController) =>
108108
new AreaController(_topicRepository, _topicMappingService),
109+
nameof(ErrorController) =>
110+
new ErrorController(_topicRepository, _topicMappingService),
109111
nameof(ControllerController) =>
110112
new ControllerController(),
111113
nameof(SitemapController) =>

OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Startup.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public static void Configure(IApplicationBuilder app) {
7777
| Configure: Error Pages
7878
\-----------------------------------------------------------------------------------------------------------------------*/
7979
app.UseDeveloperExceptionPage();
80+
app.UseStatusCodePagesWithReExecute("/Error/{0}");
8081

8182
/*------------------------------------------------------------------------------------------------------------------------
8283
| Configure: Server defaults
@@ -88,11 +89,13 @@ public static void Configure(IApplicationBuilder app) {
8889
| Configure: MVC
8990
\-----------------------------------------------------------------------------------------------------------------------*/
9091
app.UseEndpoints(endpoints => {
92+
endpoints.MapTopicErrors();
9193
endpoints.MapDefaultAreaControllerRoute();
9294
endpoints.MapDefaultControllerRoute();
9395
endpoints.MapImplicitAreaControllerRoute();
9496
endpoints.MapTopicAreaRoute();
9597
endpoints.MapTopicRoute("Web");
98+
endpoints.MapTopicRoute("Error");
9699
endpoints.MapTopicSitemap();
97100
endpoints.MapTopicRedirect();
98101
endpoints.MapControllers();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@using OnTopic.ViewModels
2+
@model PageTopicViewModel
3+
@Model?.Title

OnTopic.AspNetCore.Mvc.IntegrationTests/ServiceCollectionExtensionsTests.cs

Lines changed: 29 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
| Client Ignia, LLC
44
| Project Topics Library
55
\=============================================================================================================================*/
6+
using System.Net;
67
using Microsoft.AspNetCore.Routing;
78

89
namespace OnTopic.AspNetCore.Mvc.IntegrationTests {
@@ -33,90 +34,32 @@ public ServiceCollectionExtensionsTests(WebApplicationFactory<Startup> factory)
3334
}
3435

3536
/*==========================================================================================================================
36-
| TEST: MAP TOPIC ROUTE: RESPONDS TO REQUEST
37+
| TEST: REQUEST PAGE: EXPECTED RESULTS
3738
\-------------------------------------------------------------------------------------------------------------------------*/
3839
/// <summary>
39-
/// Evaluates a route associated with <see cref="ServiceCollectionExtensions.MapTopicRoute(IEndpointRouteBuilder, String,
40-
/// String, String)"/> and confirms that it responds appropriately.
40+
/// Evaluates various routes enabled by the routing extension methods to ensure they correctly map to the expected
41+
/// controllers, actions, and views.
4142
/// </summary>
42-
[Fact]
43-
public async Task MapTopicRoute_RespondsToRequest() {
44-
45-
var client = _factory.CreateClient();
46-
var uri = new Uri($"/Web/ContentList/", UriKind.Relative);
47-
var response = await client.GetAsync(uri).ConfigureAwait(false);
48-
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
49-
50-
response.EnsureSuccessStatusCode();
51-
52-
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString());
53-
Assert.Equal("~/Views/ContentList/ContentList.cshtml", content);
54-
55-
}
56-
57-
/*==========================================================================================================================
58-
| TEST: MAP TOPIC AREA ROUTE: RESPONDS TO REQUEST
59-
\-------------------------------------------------------------------------------------------------------------------------*/
60-
/// <summary>
61-
/// Evaluates a route associated with <see cref="ServiceCollectionExtensions.MapTopicAreaRoute(IEndpointRouteBuilder)"/>
62-
/// and confirms that it responds appropriately.
63-
/// </summary>
64-
[Fact]
65-
public async Task MapTopicAreaRoute_RespondsToRequest() {
66-
67-
var client = _factory.CreateClient();
68-
var uri = new Uri($"/Area/Area/", UriKind.Relative);
69-
var response = await client.GetAsync(uri).ConfigureAwait(false);
70-
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
71-
72-
response.EnsureSuccessStatusCode();
73-
74-
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString());
75-
Assert.Equal("~/Areas/Area/Views/ContentType/ContentType.cshtml", content);
76-
77-
}
78-
79-
/*==========================================================================================================================
80-
| TEST: MAP DEFAULT AREA CONTROLLER ROUTE: RESPONDS TO REQUEST
81-
\-------------------------------------------------------------------------------------------------------------------------*/
82-
/// <summary>
83-
/// Evaluates a route associated with <see cref="ServiceCollectionExtensions.MapDefaultAreaControllerRoute(
84-
/// IEndpointRouteBuilder)"/> and confirms that it responds appropriately.
85-
/// </summary>
86-
[Fact]
87-
public async Task MapDefaultAreaControllerRoute_RespondsToRequest() {
43+
[Theory]
44+
[InlineData("/Web/ContentList/", "~/Views/ContentList/ContentList.cshtml")] // MapTopicRoute()
45+
[InlineData("/Area/Area/", "~/Areas/Area/Views/ContentType/ContentType.cshtml")] // MapTopicAreaRoute()
46+
[InlineData("/Area/Controller/AreaAction/", "~/Areas/Area/Views/Controller/AreaAction.cshtml")] // MapTopicAreaRoute()
47+
[InlineData("/Area/Accordion/", "~/Views/ContentList/Accordion.cshtml")] // MapImplicitAreaControllerRoute()
48+
[InlineData("/Topic/3/", "~/Views/ContentList/ContentList.cshtml")] // MapTopicRedirect()
49+
[InlineData("/Error/404", "400")] // MapTopicErrors()
50+
[InlineData("/Error/Http/404", "400")] // MapDefaultControllerRoute()
51+
[InlineData("/Error/Unauthorized/", "Unauthorized")] // MapTopicRoute()
52+
public async Task RequestPage_ExpectedResults(string path, string expectedContent) {
8853

8954
var client = _factory.CreateClient();
90-
var uri = new Uri($"/Area/Controller/AreaAction/", UriKind.Relative);
55+
var uri = new Uri(path, UriKind.Relative);
9156
var response = await client.GetAsync(uri).ConfigureAwait(false);
92-
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
57+
var actualContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
9358

9459
response.EnsureSuccessStatusCode();
9560

9661
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString());
97-
Assert.Equal("~/Areas/Area/Views/Controller/AreaAction.cshtml", content);
98-
99-
}
100-
101-
/*==========================================================================================================================
102-
| TEST: MAP IMPLCIT AREA CONTROLLER ROUTE: RESPONDS TO REQUEST
103-
\-------------------------------------------------------------------------------------------------------------------------*/
104-
/// <summary>
105-
/// Evaluates a route associated with <see cref="ServiceCollectionExtensions.MapDefaultAreaControllerRoute(
106-
/// IEndpointRouteBuilder)"/> and confirms that it responds appropriately.
107-
/// </summary>
108-
[Fact]
109-
public async Task MapImplicitAreaControllerRoute_RespondsToRequest() {
110-
111-
var client = _factory.CreateClient();
112-
var uri = new Uri($"/Area/Accordion/", UriKind.Relative);
113-
var response = await client.GetAsync(uri).ConfigureAwait(false);
114-
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
115-
116-
response.EnsureSuccessStatusCode();
117-
118-
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString());
119-
Assert.Equal("~/Views/ContentList/Accordion.cshtml", content);
62+
Assert.Equal(expectedContent, actualContent);
12063

12164
}
12265

@@ -143,26 +86,26 @@ public async Task MapTopicSitemap_RespondsToRequest() {
14386
}
14487

14588
/*==========================================================================================================================
146-
| TEST: MAP TOPIC REDIRECT: REDIRECTS REQUEST
89+
| TEST: USE STATUS CODE PAGES: RETURNS EXPECTED STATUS CODE
14790
\-------------------------------------------------------------------------------------------------------------------------*/
14891
/// <summary>
149-
/// Evaluates a route associated with <see cref="ServiceCollectionExtensions.MapTopicRedirect(IEndpointRouteBuilder)"/>
150-
/// and confirms that it responds appropriately.
92+
/// Evaluates a route with an error, and confirms that it returns a page with the expected status code.
15193
/// </summary>
152-
[Fact]
153-
public async Task MapTopicRedirect_RedirectsRequest() {
94+
[Theory]
95+
[InlineData("/MissingPage/", HttpStatusCode.NotFound, "400")]
96+
[InlineData("/Web/Container/", HttpStatusCode.Forbidden, "400")]
97+
public async Task UseStatusCodePages_ReturnsExpectedStatusCode(string path, HttpStatusCode statusCode, string expectedContent) {
15498

15599
var client = _factory.CreateClient();
156-
var uri = new Uri($"/Topic/3/", UriKind.Relative);
100+
var uri = new Uri(path, UriKind.Relative);
157101
var response = await client.GetAsync(uri).ConfigureAwait(false);
158-
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
159-
160-
response.EnsureSuccessStatusCode();
102+
var actualContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
161103

104+
Assert.Equal(statusCode, response.StatusCode);
162105
Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString());
163-
Assert.Equal("~/Views/ContentList/ContentList.cshtml", content);
106+
Assert.Equal(expectedContent, actualContent);
164107

165108
}
166109

167-
}
168-
}
110+
} //Class
111+
} //Namespace
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*==============================================================================================================================
2+
| Author Ignia, LLC
3+
| Client Ignia, LLC
4+
| Project Topics Library
5+
\=============================================================================================================================*/
6+
using Microsoft.AspNetCore.Mvc;
7+
using OnTopic.AspNetCore.Mvc;
8+
using OnTopic.AspNetCore.Mvc.Controllers;
9+
using OnTopic.AspNetCore.Mvc.Tests.TestDoubles;
10+
using OnTopic.Data.Caching;
11+
using OnTopic.Mapping;
12+
using OnTopic.Repositories;
13+
using OnTopic.ViewModels;
14+
15+
namespace OnTopic.Tests {
16+
17+
/*============================================================================================================================
18+
| CLASS: ERROR CONTROLLER TEST
19+
\---------------------------------------------------------------------------------------------------------------------------*/
20+
/// <summary>
21+
/// Provides unit tests for the <see cref="ErrorController"/>.
22+
/// </summary>
23+
[ExcludeFromCodeCoverage]
24+
public class ErrorControllerTest: IClassFixture<TestTopicRepository> {
25+
26+
/*==========================================================================================================================
27+
| PRIVATE VARIABLES
28+
\-------------------------------------------------------------------------------------------------------------------------*/
29+
readonly ITopicRepository _topicRepository;
30+
readonly ITopicMappingService _topicMappingService;
31+
readonly ControllerContext _context;
32+
33+
/*==========================================================================================================================
34+
| CONSTRUCTOR
35+
\-------------------------------------------------------------------------------------------------------------------------*/
36+
/// <summary>
37+
/// Initializes a new instance of the <see cref="ErrorControllerTest"/> with shared resources.
38+
/// </summary>
39+
/// <remarks>
40+
/// This uses the <see cref="StubTopicRepository"/> to provide data, and then <see cref="CachedTopicRepository"/> to
41+
/// manage the in-memory representation of the data. While this introduces some overhead to the tests, the latter is a
42+
/// relatively lightweight façade to any <see cref="ITopicRepository"/>, and prevents the need to duplicate logic for
43+
/// crawling the object graph. In addition, it initializes a shared <see cref="Topic"/> reference to use for the various
44+
/// tests.
45+
/// </remarks>
46+
public ErrorControllerTest(TestTopicRepository topicRepository) {
47+
48+
/*------------------------------------------------------------------------------------------------------------------------
49+
| Establish dependencies
50+
\-----------------------------------------------------------------------------------------------------------------------*/
51+
_topicRepository = new CachedTopicRepository(topicRepository);
52+
_topicMappingService = new TopicMappingService(_topicRepository, new TopicViewModelLookupService());
53+
_context = FakeControllerContext.GetControllerContext("Error");
54+
55+
}
56+
57+
/*==========================================================================================================================
58+
| TEST: ERROR CONTROLLER: HTTP: RETURNS EXPECTED ERROR
59+
\-------------------------------------------------------------------------------------------------------------------------*/
60+
/// <summary>
61+
/// Triggers the <see cref="ErrorController.HttpAsync(Int32)" /> action with different status codes, and ensures that the
62+
/// expected <see cref="Topic"/> is returned in the <see cref="TopicViewResult"/>.
63+
/// </summary>
64+
[Theory]
65+
[InlineData(405, "405")] // Exact match
66+
[InlineData(412, "400")] // Fallback to category
67+
[InlineData(512, "Error")] // Fallback to root topic
68+
public async void ErrorController_Http_ReturnsExpectedError(int errorCode, string expectedContent) {
69+
70+
var controller = new ErrorController(_topicRepository, _topicMappingService) {
71+
ControllerContext = new(_context)
72+
};
73+
var result = await controller.HttpAsync(errorCode).ConfigureAwait(false) as TopicViewResult;
74+
var model = result?.Model as PageTopicViewModel;
75+
76+
controller.Dispose();
77+
78+
Assert.NotNull(result);
79+
Assert.Equal(expectedContent, model?.Title);
80+
81+
}
82+
83+
} //Class
84+
} //Namespace

0 commit comments

Comments
 (0)