diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..671f86b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,60 @@ +# Set default behavior to automatically normalize line endings. +* text=auto + +# Collapse these files in PRs by default +*.xlf linguist-generated=true +*.lcl linguist-generated=true + +*.jpg binary +*.png binary +*.gif binary + +# Force bash scripts to always use lf line endings so that if a repo is accessed +# in Unix via a file share from Windows, the scripts will work. +*.in text eol=lf +*.sh text eol=lf + +# Likewise, force cmd and batch scripts to always use crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +*.cs text=auto diff=csharp +*.vb text=auto +*.resx text=auto +*.c text=auto +*.cpp text=auto +*.cxx text=auto +*.h text=auto +*.hxx text=auto +*.py text=auto +*.rb text=auto +*.java text=auto +*.html text=auto +*.htm text=auto +*.css text=auto +*.scss text=auto +*.sass text=auto +*.less text=auto +*.js text=auto +*.lisp text=auto +*.clj text=auto +*.sql text=auto +*.php text=auto +*.lua text=auto +*.m text=auto +*.asm text=auto +*.erl text=auto +*.fs text=auto +*.fsx text=auto +*.hs text=auto + +*.csproj text=auto +*.vbproj text=auto +*.fsproj text=auto +*.dbproj text=auto +*.sln text=auto eol=crlf + +# Set linguist language for .h files explicitly based on +# https://github.com/github/linguist/issues/1626#issuecomment-401442069 +# this only affects the repo's language statistics +*.h linguist-language=C diff --git a/src/Teapot.Web.Tests/HttpMethods.cs b/src/Teapot.Web.Tests/HttpMethods.cs index 1ded412..bf5584a 100644 --- a/src/Teapot.Web.Tests/HttpMethods.cs +++ b/src/Teapot.Web.Tests/HttpMethods.cs @@ -4,5 +4,5 @@ namespace Teapot.Web.Tests; public class HttpMethods { - public static HttpMethod[] All => new[] { Get, Put, Post, Delete, Head, Options, Trace, Patch }; + public static HttpMethod[] All => [Get, Put, Post, Delete, Head, Options, Trace, Patch]; } diff --git a/src/Teapot.Web.Tests/IntegrationTests/CustomHeaderTests.cs b/src/Teapot.Web.Tests/IntegrationTests/CustomHeaderTests.cs index 86e9c4a..48b136b 100644 --- a/src/Teapot.Web.Tests/IntegrationTests/CustomHeaderTests.cs +++ b/src/Teapot.Web.Tests/IntegrationTests/CustomHeaderTests.cs @@ -1,27 +1,31 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Teapot.Web.Controllers; namespace Teapot.Web.Tests.IntegrationTests; -public class CustomHeaderTests { - private static readonly HttpClient _httpClient = new WebApplicationFactory().CreateDefaultClient(); + +public class CustomHeaderTests +{ [Test] - public async Task CanSetCustomHeaders() { + public async Task CanSetCustomHeaders() + { + HttpClient httpClient = new WebApplicationFactory().CreateDefaultClient(); string uri = "/200"; string headerName = "Foo"; string headerValue = "bar"; using HttpRequestMessage request = new(HttpMethod.Get, uri); - request.Headers.Add($"{StatusController.CUSTOM_RESPONSE_HEADER_PREFIX}{headerName}", headerValue); + request.Headers.Add($"{StatusExtensions.CUSTOM_RESPONSE_HEADER_PREFIX}{headerName}", headerValue); - using var response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await httpClient.SendAsync(request); - var headers = response.Headers; - Assert.That(headers.Contains(headerName), Is.True); - Assert.That(headers.TryGetValues(headerName, out var values), Is.True); - Assert.That(values, Is.Not.Null); - Assert.That(values.Count(), Is.EqualTo(1)); - Assert.That(values.First(), Is.EqualTo(headerValue)); + System.Net.Http.Headers.HttpResponseHeaders headers = response.Headers; + Assert.Multiple(() => + { + Assert.That(headers.Contains(headerName), Is.True); + Assert.That(headers.TryGetValues(headerName, out IEnumerable? values), Is.True); + Assert.That(values, Is.Not.Null); + Assert.That(values!.Count(), Is.EqualTo(1)); + Assert.That(values!.First(), Is.EqualTo(headerValue)); + }); } - } diff --git a/src/Teapot.Web.Tests/IntegrationTests/RandomTests.cs b/src/Teapot.Web.Tests/IntegrationTests/RandomTests.cs index da6e03e..6ed59b4 100644 --- a/src/Teapot.Web.Tests/IntegrationTests/RandomTests.cs +++ b/src/Teapot.Web.Tests/IntegrationTests/RandomTests.cs @@ -3,17 +3,22 @@ namespace Teapot.Web.Tests.IntegrationTests; [TestFixtureSource(typeof(HttpMethods), nameof(HttpMethods.All))] -public class RandomTests +public class RandomTests(HttpMethod httpMethod) { - private readonly HttpMethod _httpMethod; - - private static readonly HttpClient _httpClient = new WebApplicationFactory().CreateDefaultClient(); + [OneTimeSetUp] + public void OneTimeSetUp() + { + _httpClient = new WebApplicationFactory().CreateDefaultClient(); + } - public RandomTests(HttpMethod httpMethod) + [OneTimeTearDown] + public void OneTimeTearDown() { - _httpMethod = httpMethod; + _httpClient.Dispose(); } + private HttpClient _httpClient = null!; + [TestCase("foo")] [TestCase("200,x")] [TestCase("200.0")] @@ -21,9 +26,9 @@ public RandomTests(HttpMethod httpMethod) [TestCase("-1-1")] public async Task ParseError(string input) { - var uri = $"/random/{input}"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/random/{input}"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } @@ -34,11 +39,11 @@ public async Task ParseError(string input) [TestCase("190-199,570-590")] public async Task ParsedAsExpected(string input) { - var uri = $"/random/{input}"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/random/{input}"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That((int)response.StatusCode, Is.InRange(100, 599)); - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(body.ReplaceLineEndings(), Does.EndWith("Unknown Code")); diff --git a/src/Teapot.Web.Tests/IntegrationTests/StatusCodeTests.cs b/src/Teapot.Web.Tests/IntegrationTests/StatusCodeTests.cs index dbe5b4e..b2c1a51 100644 --- a/src/Teapot.Web.Tests/IntegrationTests/StatusCodeTests.cs +++ b/src/Teapot.Web.Tests/IntegrationTests/StatusCodeTests.cs @@ -1,28 +1,32 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Teapot.Web.Controllers; namespace Teapot.Web.Tests.IntegrationTests; [TestFixtureSource(typeof(HttpMethods), nameof(HttpMethods.All))] -public class StatusCodeTests +public class StatusCodeTests(HttpMethod httpMethod) { - private readonly HttpMethod _httpMethod; - - private static readonly HttpClient _httpClient = new WebApplicationFactory().CreateDefaultClient(); + [OneTimeSetUp] + public void OneTimeSetUp() + { + _httpClient = new WebApplicationFactory().CreateDefaultClient(); + } - public StatusCodeTests(HttpMethod httpMethod) + [OneTimeTearDown] + public void OneTimeTearDown() { - _httpMethod = httpMethod; + _httpClient.Dispose(); } + private HttpClient _httpClient = null!; + [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesWithContent))] public async Task ResponseWithContent([Values] TestCase testCase) { - var uri = $"/{testCase.Code}"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/{testCase.Code}"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That((int)response.StatusCode, Is.EqualTo(testCase.Code)); - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(body.ReplaceLineEndings(), Is.EqualTo(testCase.Body)); @@ -35,11 +39,11 @@ public async Task ResponseWithContent([Values] TestCase testCase) [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesWithContent))] public async Task ResponseWithContentSuppressedViaQs([Values] TestCase testCase) { - var uri = $"/{testCase.Code}?{nameof(CustomHttpStatusCodeResult.SuppressBody)}=true"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/{testCase.Code}?{nameof(CustomHttpStatusCodeResult.SuppressBody)}=true"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That((int)response.StatusCode, Is.EqualTo(testCase.Code)); - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(body, Is.Empty); @@ -52,12 +56,12 @@ public async Task ResponseWithContentSuppressedViaQs([Values] TestCase testCase) [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesWithContent))] public async Task ResponseWithContentSuppressedViaHeader([Values] TestCase testCase) { - var uri = $"/{testCase.Code}"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - httpRequest.Headers.Add(StatusController.SUPPRESS_BODY_HEADER, "true"); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/{testCase.Code}"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + httpRequest.Headers.Add(StatusExtensions.SUPPRESS_BODY_HEADER, "true"); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That((int)response.StatusCode, Is.EqualTo(testCase.Code)); - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(body, Is.Empty); @@ -70,11 +74,11 @@ public async Task ResponseWithContentSuppressedViaHeader([Values] TestCase testC [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesNoContent))] public async Task ResponseNoContent([Values] TestCase testCase) { - var uri = $"/{testCase.Code}"; - using var httpRequest = new HttpRequestMessage(_httpMethod, uri); - using var response = await _httpClient.SendAsync(httpRequest); + string uri = $"/{testCase.Code}"; + using HttpRequestMessage httpRequest = new(httpMethod, uri); + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest); Assert.That((int)response.StatusCode, Is.EqualTo(testCase.Code)); - var body = await response.Content.ReadAsStringAsync(); + string body = await response.Content.ReadAsStringAsync(); Assert.Multiple(() => { Assert.That(body, Is.Empty); diff --git a/src/Teapot.Web.Tests/Teapot.Web.Tests.csproj b/src/Teapot.Web.Tests/Teapot.Web.Tests.csproj index 568b4c6..faaae11 100644 --- a/src/Teapot.Web.Tests/Teapot.Web.Tests.csproj +++ b/src/Teapot.Web.Tests/Teapot.Web.Tests.csproj @@ -1,20 +1,26 @@  - net7.0 + net8.0 enable enable false - - - - - - - + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Teapot.Web.Tests/TestCases.cs b/src/Teapot.Web.Tests/TestCases.cs index 7e59326..ece065d 100644 --- a/src/Teapot.Web.Tests/TestCases.cs +++ b/src/Teapot.Web.Tests/TestCases.cs @@ -6,14 +6,14 @@ namespace Teapot.Web.Tests; public class TestCases { - private static readonly TeapotStatusCodeResults All = new( - new AmazonStatusCodeResults(), - new CloudflareStatusCodeResults(), - new EsriStatusCodeResults(), - new LaravelStatusCodeResults(), - new MicrosoftStatusCodeResults(), - new NginxStatusCodeResults(), - new TwitterStatusCodeResults() + private static readonly TeapotStatusCodeMetadataCollection All = new( + new AmazonStatusCodeMetadata(), + new CloudflareStatusCodeMetadata(), + new EsriStatusCodeMetadata(), + new LaravelStatusCodeMetadata(), + new MicrosoftStatusCodeMetadata(), + new NginxStatusCodeMetadata(), + new TwitterStatusCodeMetadata() ); private static readonly HttpStatusCode[] NoContentStatusCodes = new[] @@ -34,12 +34,12 @@ public class TestCases private static TestCase Map(HttpStatusCode code) { - var key = (int)code; + int key = (int)code; return new(key, All[key].Description, All[key].Body); } - private static TestCase Map(KeyValuePair code) + private static TestCase Map(KeyValuePair code) { - return new TestCase(code.Key, code.Value.Description, code.Value.Body); + return new(code.Key, code.Value.Description, code.Value.Body); } } diff --git a/src/Teapot.Web.Tests/UnitTests/CustomHttpStatusCodeResultTests.cs b/src/Teapot.Web.Tests/UnitTests/CustomHttpStatusCodeResultTests.cs index 35d7f0b..dfd88e1 100644 --- a/src/Teapot.Web.Tests/UnitTests/CustomHttpStatusCodeResultTests.cs +++ b/src/Teapot.Web.Tests/UnitTests/CustomHttpStatusCodeResultTests.cs @@ -1,8 +1,5 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using System.Text.Json; using Teapot.Web.Models; @@ -14,19 +11,18 @@ public class CustomHttpStatusCodeResultTests private HttpContext _httpContext; private HttpResponseFeature _httpResponseFeature; private Stream _body; - private Mock _mockActionContext; [SetUp] public void Setup() { - var logger = new Mock(); - var loggerFactory = new Mock(); - var serviceProvider = new Mock(); + Mock logger = new(); + Mock loggerFactory = new(); + Mock serviceProvider = new(); loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(logger.Object); serviceProvider.Setup(x => x.GetService(typeof(ILogger))).Returns(logger.Object); serviceProvider.Setup(x => x.GetService(typeof(ILoggerFactory))).Returns(loggerFactory.Object); - var featureCollection = new FeatureCollection(); + FeatureCollection featureCollection = new(); _httpResponseFeature = new HttpResponseFeature(); _body = new MemoryStream(); featureCollection.Set(new HttpRequestFeature()); @@ -37,30 +33,26 @@ public void Setup() { RequestServices = serviceProvider.Object }; - - var routeData = new Mock(); - var actionDescriptor = new Mock(); - _mockActionContext = new Mock(_httpContext, routeData.Object, actionDescriptor.Object); } [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesAll))] public async Task Response_Is_Correct(TestCase testCase) { - var statusCodeResult = new TeapotStatusCodeResult + TeapotStatusCodeMetadata statusCodeResult = new() { Description = testCase.Description }; - var target = new CustomHttpStatusCodeResult(testCase.Code, statusCodeResult, null, null, new()); + CustomHttpStatusCodeResult target = new(testCase.Code, statusCodeResult, null, null, new()); - await target.ExecuteResultAsync(_mockActionContext.Object); + await target.ExecuteAsync(_httpContext); Assert.Multiple(() => { Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(testCase.Code)); Assert.That(_httpContext.Response.ContentType, Is.EqualTo("text/plain")); Assert.That(_httpResponseFeature.ReasonPhrase, Is.EqualTo(testCase.Description)); }); _body.Position = 0; - var sr = new StreamReader(_body); - var body = sr.ReadToEnd(); + StreamReader sr = new(_body); + string body = sr.ReadToEnd(); Assert.Multiple(() => { Assert.That(body, Is.EqualTo(testCase.ToString())); Assert.That(_httpContext.Response.ContentLength, Is.EqualTo(body.Length)); @@ -69,25 +61,25 @@ public async Task Response_Is_Correct(TestCase testCase) { [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesAll))] public async Task Response_Json_Is_Correct(TestCase testCase) { - var statusCodeResult = new TeapotStatusCodeResult + TeapotStatusCodeMetadata statusCodeResult = new() { Description = testCase.Description }; - var target = new CustomHttpStatusCodeResult(testCase.Code, statusCodeResult, null, null, new()); + CustomHttpStatusCodeResult target = new(testCase.Code, statusCodeResult, null, null, new()); _httpContext.Request.Headers.Accept = "application/json"; - await target.ExecuteResultAsync(_mockActionContext.Object); + await target.ExecuteAsync(_httpContext); Assert.Multiple(() => { Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(testCase.Code)); Assert.That(_httpContext.Response.ContentType, Is.EqualTo("application/json")); Assert.That(_httpResponseFeature.ReasonPhrase, Is.EqualTo(testCase.Description)); }); _body.Position = 0; - var sr = new StreamReader(_body); - var body = sr.ReadToEnd(); - var expectedBody = JsonSerializer.Serialize(testCase); + StreamReader sr = new(_body); + string body = sr.ReadToEnd(); + string expectedBody = JsonSerializer.Serialize(testCase); Assert.Multiple(() => { Assert.That(body, Is.EqualTo(expectedBody)); Assert.That(_httpContext.Response.ContentLength, Is.EqualTo(body.Length)); @@ -96,7 +88,7 @@ public async Task Response_Json_Is_Correct(TestCase testCase) { [TestCaseSource(typeof(TestCases), nameof(TestCases.StatusCodesNoContent))] public async Task Response_No_Content(TestCase testCase) { - var statusCodeResult = new TeapotStatusCodeResult + TeapotStatusCodeMetadata statusCodeResult = new() { Description = testCase.Description, ExcludeBody = true @@ -105,17 +97,17 @@ public async Task Response_No_Content(TestCase testCase) { _httpContext.Response.Headers["Content-Type"] = "text/plain"; _httpContext.Response.Headers["Content-Length"] = "42"; - var target = new CustomHttpStatusCodeResult(testCase.Code, statusCodeResult, null, null, new()); + CustomHttpStatusCodeResult target = new(testCase.Code, statusCodeResult, null, null, new()); - await target.ExecuteResultAsync(_mockActionContext.Object); + await target.ExecuteAsync(_httpContext); Assert.Multiple(() => { Assert.That(_httpContext.Response.StatusCode, Is.EqualTo(testCase.Code)); Assert.That(_httpContext.Response.ContentType, Is.Null); Assert.That(_httpResponseFeature.ReasonPhrase, Is.EqualTo(testCase.Description)); }); _body.Position = 0; - var sr = new StreamReader(_body); - var body = sr.ReadToEnd(); + StreamReader sr = new(_body); + string body = sr.ReadToEnd(); Assert.Multiple(() => { Assert.That(body, Is.Empty); Assert.That(_httpContext.Response.ContentLength, Is.Null); diff --git a/src/Teapot.Web.Tests/UnitTests/HttpRequestHelper.cs b/src/Teapot.Web.Tests/UnitTests/HttpRequestHelper.cs new file mode 100644 index 0000000..492ae19 --- /dev/null +++ b/src/Teapot.Web.Tests/UnitTests/HttpRequestHelper.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace Teapot.Web.Tests.UnitTests; + +internal static class HttpRequestHelper +{ + public static Mock GenerateMockRequest() + { + Mock request = new(); + request.Setup(Setup => Setup.Headers).Returns(new HeaderDictionary()); + return request; + } +} diff --git a/src/Teapot.Web.Tests/UnitTests/SleepTests.cs b/src/Teapot.Web.Tests/UnitTests/SleepTests.cs index ce1fe04..7f855c4 100644 --- a/src/Teapot.Web.Tests/UnitTests/SleepTests.cs +++ b/src/Teapot.Web.Tests/UnitTests/SleepTests.cs @@ -1,6 +1,4 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Teapot.Web.Controllers; using Teapot.Web.Models; using Teapot.Web.Models.Unofficial; @@ -8,30 +6,25 @@ namespace Teapot.Web.Tests.UnitTests; public class SleepTests { private const int Sleep = 500; - private TeapotStatusCodeResults _statusCodes; + private TeapotStatusCodeMetadataCollection _statusCodes; [SetUp] public void Setup() { _statusCodes = new( - new AmazonStatusCodeResults(), - new CloudflareStatusCodeResults(), - new EsriStatusCodeResults(), - new LaravelStatusCodeResults(), - new MicrosoftStatusCodeResults(), - new NginxStatusCodeResults(), - new TwitterStatusCodeResults() + new AmazonStatusCodeMetadata(), + new CloudflareStatusCodeMetadata(), + new EsriStatusCodeMetadata(), + new LaravelStatusCodeMetadata(), + new MicrosoftStatusCodeMetadata(), + new NginxStatusCodeMetadata(), + new TwitterStatusCodeMetadata() ); } [Test] public void SleepReadFromQuery() { - StatusController controller = new(_statusCodes) { - ControllerContext = new ControllerContext { - HttpContext = new DefaultHttpContext() - } - }; - - IActionResult result = controller.StatusCode(200, Sleep, null); + Mock request = HttpRequestHelper.GenerateMockRequest(); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, Sleep, null, null, request.Object, _statusCodes); Assert.Multiple(() => { Assert.That(result, Is.InstanceOf()); @@ -42,15 +35,12 @@ public void SleepReadFromQuery() { } [Test] - public void SleepReadFromHeader() { - StatusController controller = new(_statusCodes) { - ControllerContext = new ControllerContext { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SLEEP_HEADER, Sleep.ToString()); + public void SleepReadFromHeader() + { + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, Sleep.ToString()); - IActionResult result = controller.StatusCode(200, null, null); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, null, null, request.Object, _statusCodes); Assert.Multiple(() => { Assert.That(result, Is.InstanceOf()); @@ -62,14 +52,10 @@ public void SleepReadFromHeader() { [Test] public void SleepReadFromQSTakesPriorityHeader() { - StatusController controller = new(_statusCodes) { - ControllerContext = new ControllerContext { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SLEEP_HEADER, Sleep.ToString()); + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, Sleep.ToString()); - IActionResult result = controller.StatusCode(200, Sleep * 2, null); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, Sleep * 2, null, null, request.Object, _statusCodes); Assert.Multiple(() => { Assert.That(result, Is.InstanceOf()); @@ -80,21 +66,20 @@ public void SleepReadFromQSTakesPriorityHeader() { } [Test] - public void BadSleepHeaderIgnored() { - StatusController controller = new(_statusCodes) { - ControllerContext = new ControllerContext { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SLEEP_HEADER, "invalid"); + public void BadSleepHeaderIgnored() + { + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, "invalid"); - IActionResult result = controller.StatusCode(200, null, null); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, null, null, request.Object, _statusCodes); - Assert.Multiple(() => { + Assert.Multiple(() => + { Assert.That(result, Is.InstanceOf()); CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result; Assert.That(r.Sleep, Is.Null); }); } + } diff --git a/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs b/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs index eec2ba2..a0e8fc5 100644 --- a/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs +++ b/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs @@ -1,23 +1,23 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Teapot.Web.Controllers; using Teapot.Web.Models; using Teapot.Web.Models.Unofficial; namespace Teapot.Web.Tests.UnitTests; -public class SuppressBodyTests { - private TeapotStatusCodeResults _statusCodes; +public class SuppressBodyTests +{ + private TeapotStatusCodeMetadataCollection _statusCodes; [SetUp] - public void Setup() { + public void Setup() + { _statusCodes = new( - new AmazonStatusCodeResults(), - new CloudflareStatusCodeResults(), - new EsriStatusCodeResults(), - new LaravelStatusCodeResults(), - new MicrosoftStatusCodeResults(), - new NginxStatusCodeResults(), - new TwitterStatusCodeResults() + new AmazonStatusCodeMetadata(), + new CloudflareStatusCodeMetadata(), + new EsriStatusCodeMetadata(), + new LaravelStatusCodeMetadata(), + new MicrosoftStatusCodeMetadata(), + new NginxStatusCodeMetadata(), + new TwitterStatusCodeMetadata() ); } @@ -25,18 +25,13 @@ public void Setup() { [TestCase(true)] [TestCase(false)] [TestCase(null)] - public void SuppressBodyReadFromQuery(bool? suppressBody) { - StatusController controller = new(_statusCodes) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - } - }; - - IActionResult result = controller.StatusCode(200, null, suppressBody); + public void SuppressBodyReadFromQuery(bool? suppressBody) + { + Mock request = HttpRequestHelper.GenerateMockRequest(); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, suppressBody, null, request.Object, _statusCodes); - Assert.Multiple(() => { + Assert.Multiple(() => + { Assert.That(result, Is.InstanceOf()); CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result; @@ -50,25 +45,19 @@ public void SuppressBodyReadFromQuery(bool? suppressBody) { [TestCase("")] public void SuppressBodyReadFromHeader(string? suppressBody) { - StatusController controller = new(_statusCodes) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SUPPRESS_BODY_HEADER, suppressBody); + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, suppressBody); - IActionResult result = controller.StatusCode(200, null, null); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, null, null, request.Object, _statusCodes); Assert.Multiple(() => { Assert.That(result, Is.InstanceOf()); CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result; - var expectedValue = suppressBody switch + bool? expectedValue = suppressBody switch { - string { Length: >0 } stringValue => (bool?)bool.Parse(stringValue), + string { Length: > 0 } stringValue => bool.Parse(stringValue), _ => null }; Assert.That(r.SuppressBody, Is.EqualTo(expectedValue)); @@ -84,25 +73,19 @@ public void SuppressBodyReadFromHeader(string? suppressBody) [TestCase("", false)] public void SuppressBodyReadFromQSTakesPriorityHeader(string? headerValue, bool? queryStringValue) { - StatusController controller = new(_statusCodes) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SUPPRESS_BODY_HEADER, headerValue); + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, headerValue); - IActionResult result = controller.StatusCode(200, null, queryStringValue); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, queryStringValue, null, request.Object, _statusCodes); Assert.Multiple(() => { Assert.That(result, Is.InstanceOf()); - + CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result; - var expectedValue = queryStringValue.HasValue ? queryStringValue.Value : headerValue switch + bool? expectedValue = queryStringValue ?? headerValue switch { - string { Length: > 0 } stringValue => (bool?)bool.Parse(stringValue), + string { Length: > 0 } stringValue => bool.Parse(stringValue), _ => null }; Assert.That(r.SuppressBody, Is.EqualTo(expectedValue)); @@ -112,16 +95,10 @@ public void SuppressBodyReadFromQSTakesPriorityHeader(string? headerValue, bool? [Test] public void BadSuppressBodyHeaderIgnored() { - StatusController controller = new(_statusCodes) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - } - }; - controller.ControllerContext.HttpContext.Request.Headers.Add(StatusController.SUPPRESS_BODY_HEADER, "invalid"); + Mock request = HttpRequestHelper.GenerateMockRequest(); + request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, "invalid"); - IActionResult result = controller.StatusCode(200, null, null); + IResult result = StatusExtensions.HandleStatusRequestAsync(200, null, null, null, request.Object, _statusCodes); Assert.Multiple(() => { diff --git a/src/Teapot.Web.Tests/UnitTests/TeapotStatusCodeResultsTests.cs b/src/Teapot.Web.Tests/UnitTests/TeapotStatusCodeResultsTests.cs index e7e7487..6238ed4 100644 --- a/src/Teapot.Web.Tests/UnitTests/TeapotStatusCodeResultsTests.cs +++ b/src/Teapot.Web.Tests/UnitTests/TeapotStatusCodeResultsTests.cs @@ -5,19 +5,19 @@ namespace Teapot.Web.Tests.UnitTests; public class TeapotStatusCodeResultsTests { - private TeapotStatusCodeResults _target; + private TeapotStatusCodeMetadataCollection _target; [SetUp] public void Setup() { - _target = new TeapotStatusCodeResults( - new AmazonStatusCodeResults(), - new CloudflareStatusCodeResults(), - new EsriStatusCodeResults(), - new LaravelStatusCodeResults(), - new MicrosoftStatusCodeResults(), - new NginxStatusCodeResults(), - new TwitterStatusCodeResults() + _target = new TeapotStatusCodeMetadataCollection( + new AmazonStatusCodeMetadata(), + new CloudflareStatusCodeMetadata(), + new EsriStatusCodeMetadata(), + new LaravelStatusCodeMetadata(), + new MicrosoftStatusCodeMetadata(), + new NginxStatusCodeMetadata(), + new TwitterStatusCodeMetadata() ); } diff --git a/src/Teapot.Web/Controllers/StatusController.cs b/src/Teapot.Web/Controllers/StatusController.cs deleted file mode 100644 index 404a9a3..0000000 --- a/src/Teapot.Web/Controllers/StatusController.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Linq; -using System.Net; -using Teapot.Web.Models; - -namespace Teapot.Web.Controllers; - -public class StatusController : Controller { - public const string SLEEP_HEADER = "X-HttpStatus-Sleep"; - public const string SUPPRESS_BODY_HEADER = "X-HttpStatus-SuppressBody"; - public const string CUSTOM_RESPONSE_HEADER_PREFIX = "X-HttpStatus-Response-"; - - private readonly TeapotStatusCodeResults _statusCodes; - - public StatusController(TeapotStatusCodeResults statusCodes) - { - _statusCodes = statusCodes; - } - - [Route("")] - public IActionResult Index() => View(_statusCodes); - - [Route("{statusCode:int}", Name = "StatusCode")] - [Route("{statusCode:int}/{*wildcard}", Name = "StatusCodeWildcard")] - public IActionResult StatusCode(int statusCode, int? sleep, bool? suppressBody) { - var statusData = _statusCodes.ContainsKey(statusCode) - ? _statusCodes[statusCode] - : new TeapotStatusCodeResult { Description = $"{statusCode} Unknown Code" }; - - sleep ??= FindSleepInHeader(); - suppressBody ??= FindSuppressBodyInHeader(); - - var customResponseHeaders = HttpContext.Request.Headers - .Where(header => header.Key.StartsWith(CUSTOM_RESPONSE_HEADER_PREFIX)) - .ToDictionary(header => header.Key.Replace(CUSTOM_RESPONSE_HEADER_PREFIX, string.Empty), header => header.Value); - - return new CustomHttpStatusCodeResult(statusCode, statusData, sleep, suppressBody, customResponseHeaders); - } - - [Route("Random/{range?}", Name = "Random")] - [Route("Random/{range?}/{*wildcard}", Name = "RandomWildcard")] - public IActionResult Random(string range = "100-599", int? sleep = null, bool? suppressBody = null) - { - try - { - var statusCode = GetRandomStatus(range); - return StatusCode(statusCode, sleep, suppressBody); - } - catch - { - return new StatusCodeResult((int)HttpStatusCode.BadRequest); - } - } - - private int? FindSleepInHeader() { - if (HttpContext.Request.Headers.TryGetValue(SLEEP_HEADER, out var sleepHeader) && sleepHeader.Count == 1 && sleepHeader[0] is not null) { - var val = sleepHeader[0]; - if (int.TryParse(val, out var sleepFromHeader)) { - return sleepFromHeader; - } - } - - return null; - } - - private bool? FindSuppressBodyInHeader() - { - if (HttpContext.Request.Headers.TryGetValue(SUPPRESS_BODY_HEADER, out var suppressBodyHeader) && suppressBodyHeader.Count == 1 && suppressBodyHeader[0] is not null) - { - var val = suppressBodyHeader[0]; - if (bool.TryParse(val, out var suppressBodyFromHeader)) - { - return suppressBodyFromHeader; - } - } - - return null; - } - - private int GetRandomStatus(string range) - { - // copied from https://stackoverflow.com/a/37213725/260221 - var options = range.Split(',') - .Select(x => x.Split('-')) - .Select(p => new { First = int.Parse(p.First()), Last = int.Parse(p.Last()) }) - .SelectMany(x => Enumerable.Range(x.First, x.Last - x.First + 1)) - .ToArray(); - - return options[new Random().Next(options.Length)]; - } -} \ No newline at end of file diff --git a/src/Teapot.Web/Controllers/TeapotController.cs b/src/Teapot.Web/Controllers/TeapotController.cs deleted file mode 100644 index ada80e5..0000000 --- a/src/Teapot.Web/Controllers/TeapotController.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Teapot.Web.Controllers; - -public class TeapotController : Controller -{ - [HttpGet("im-a-teapot", Name = "I'm a teapot")] - public IActionResult Teapot() => Redirect("https://www.ietf.org/rfc/rfc2324.txt"); - - [HttpGet("teapot", Name = "Default")] - public IActionResult Index() => View(); -} diff --git a/src/Teapot.Web/CustomHttpStatusCodeResult.cs b/src/Teapot.Web/CustomHttpStatusCodeResult.cs index cfa4fc3..e131f25 100644 --- a/src/Teapot.Web/CustomHttpStatusCodeResult.cs +++ b/src/Teapot.Web/CustomHttpStatusCodeResult.cs @@ -1,85 +1,88 @@ -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; using Teapot.Web.Models; namespace Teapot.Web; -public class CustomHttpStatusCodeResult : StatusCodeResult { +public class CustomHttpStatusCodeResult( + int statusCode, + TeapotStatusCodeMetadata metadata, + int? sleep, + bool? suppressBody, + Dictionary customResponseHeaders) : IResult +{ private const int SLEEP_MIN = 0; private const int SLEEP_MAX = 5 * 60 * 1000; // 5 mins in milliseconds - private readonly TeapotStatusCodeResult _statusCodeResult; - private readonly int? _sleep; - private readonly bool? _suppressBody; - private readonly Dictionary _customResponseHeaders; + private static readonly MediaTypeHeaderValue jsonMimeType = new("application/json"); - public int? Sleep => _sleep; + public int? Sleep => sleep; - public bool? SuppressBody => _suppressBody; + public bool? SuppressBody => suppressBody; - public CustomHttpStatusCodeResult([ActionResultStatusCode] int statusCode, TeapotStatusCodeResult statusCodeResult, int? sleep, bool? suppressBody, Dictionary customResponseHeaders) - : base(statusCode) { - _statusCodeResult = statusCodeResult; - _sleep = sleep; - _suppressBody = suppressBody; - _customResponseHeaders = customResponseHeaders; - } - - public override async Task ExecuteResultAsync(ActionContext context) { + public async Task ExecuteAsync(HttpContext context) + { await DoSleep(Sleep); - await base.ExecuteResultAsync(context); + context.Response.StatusCode = statusCode; - if (!string.IsNullOrEmpty(_statusCodeResult.Description)) { - var httpResponseFeature = context.HttpContext.Features.Get(); - if (httpResponseFeature is not null) { - httpResponseFeature.ReasonPhrase = _statusCodeResult.Description; + if (!string.IsNullOrEmpty(metadata.Description)) + { + IHttpResponseFeature? httpResponseFeature = context.Features.Get(); + if (httpResponseFeature is not null) + { + httpResponseFeature.ReasonPhrase = metadata.Description; } } - if (_statusCodeResult.IncludeHeaders is not null) { - foreach ((var header, var values) in _statusCodeResult.IncludeHeaders) { - context.HttpContext.Response.Headers.Add(header, values); + if (metadata.IncludeHeaders is not null) + { + foreach ((string header, string values) in metadata.IncludeHeaders) + { + context.Response.Headers.Append(header, values); } } - foreach ((string header, StringValues values) in _customResponseHeaders) { - context.HttpContext.Response.Headers.Add(header, values); + foreach ((string header, StringValues values) in customResponseHeaders) + { + context.Response.Headers.Append(header, values); } - if (_statusCodeResult.ExcludeBody || _suppressBody == true) { + if (metadata.ExcludeBody || suppressBody == true) + { //remove Content-Length and Content-Type when there isn't any body - context.HttpContext.Response.Headers.Remove("Content-Length"); - context.HttpContext.Response.Headers.Remove("Content-Type"); - } else { - var acceptTypes = context.HttpContext.Request.GetTypedHeaders().Accept; + context.Response.Headers.Remove("Content-Length"); + context.Response.Headers.Remove("Content-Type"); + } + else + { + IList acceptTypes = context.Request.GetTypedHeaders().Accept; - if (acceptTypes is not null) { - var (body, contentType) = acceptTypes.Contains(new MediaTypeHeaderValue("application/json")) switch { - true => (JsonSerializer.Serialize(new { code = StatusCode, description = _statusCodeResult.Body ?? _statusCodeResult.Description }), "application/json"), - false => (_statusCodeResult.Body ?? $"{StatusCode} {_statusCodeResult.Description}", "text/plain") - }; + (string body, string contentType) = acceptTypes.Contains(jsonMimeType) switch + { + true => (JsonSerializer.Serialize(new { code = statusCode, description = metadata.Body ?? metadata.Description }), "application/json"), + false => (metadata.Body ?? $"{statusCode} {metadata.Description}", "text/plain") + }; + + context.Response.ContentType = contentType; + context.Response.ContentLength = body.Length; + await context.Response.WriteAsync(body); - context.HttpContext.Response.ContentType = contentType; - context.HttpContext.Response.ContentLength = body.Length; - await context.HttpContext.Response.WriteAsync(body); - } } } - private static async Task DoSleep(int? sleep) { - var sleepData = Math.Clamp(sleep ?? 0, SLEEP_MIN, SLEEP_MAX); - if (sleepData > 0) { + private static async Task DoSleep(int? sleep) + { + int sleepData = Math.Clamp(sleep ?? 0, SLEEP_MIN, SLEEP_MAX); + if (sleepData > 0) + { await Task.Delay(sleepData); } } - -} +} \ No newline at end of file diff --git a/src/Teapot.Web/Dockerfile b/src/Teapot.Web/Dockerfile index f7e46b4..e35ad6d 100644 --- a/src/Teapot.Web/Dockerfile +++ b/src/Teapot.Web/Dockerfile @@ -1,11 +1,11 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["Teapot.Web/Teapot.Web.csproj", "Teapot.Web/"] RUN dotnet restore "Teapot.Web/Teapot.Web.csproj" diff --git a/src/Teapot.Web/FairUseRateLimiterExtensions.cs b/src/Teapot.Web/FairUseRateLimiterExtensions.cs new file mode 100644 index 0000000..b9d68c6 --- /dev/null +++ b/src/Teapot.Web/FairUseRateLimiterExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Threading.RateLimiting; + +namespace Teapot.Web; + +internal static class FairUseRateLimiterExtensions +{ + internal static string PolicyName = "fair-use"; + public static IServiceCollection AddFairUseRateLimiter(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.AddPolicy(PolicyName, context => + { + string? token = context.Request.Query["token"]; + if (!string.IsNullOrEmpty(token)) + { + return RateLimitPartition.GetSlidingWindowLimiter(token, + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 10, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 1000 + }); + } + else + { + // people without tokens hit the global limiter + return RateLimitPartition.GetConcurrencyLimiter("global", + _ => new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 100, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + } + }); + }); + + return services; + } + + public static WebApplication UseFairUseRateLimiter(this WebApplication app) + { + app.UseRateLimiter(); + + return app; + } +} diff --git a/src/Teapot.Web/Models/TeapotStatusCodeResult.cs b/src/Teapot.Web/Models/TeapotStatusCodeMetadata.cs similarity index 88% rename from src/Teapot.Web/Models/TeapotStatusCodeResult.cs rename to src/Teapot.Web/Models/TeapotStatusCodeMetadata.cs index d28d806..7467177 100644 --- a/src/Teapot.Web/Models/TeapotStatusCodeResult.cs +++ b/src/Teapot.Web/Models/TeapotStatusCodeMetadata.cs @@ -3,10 +3,10 @@ namespace Teapot.Web.Models; -public class TeapotStatusCodeResult +public class TeapotStatusCodeMetadata { public string Description { get; set; } = ""; - public Dictionary IncludeHeaders { get; set; } = new(); + public Dictionary IncludeHeaders { get; set; } = []; public bool ExcludeBody { get; set; } = false; public Uri? Link { get; set; } public string? Body { get; set; } diff --git a/src/Teapot.Web/Models/TeapotStatusCodeResults.cs b/src/Teapot.Web/Models/TeapotStatusCodeMetadataCollection.cs similarity index 65% rename from src/Teapot.Web/Models/TeapotStatusCodeResults.cs rename to src/Teapot.Web/Models/TeapotStatusCodeMetadataCollection.cs index 017830c..013be44 100644 --- a/src/Teapot.Web/Models/TeapotStatusCodeResults.cs +++ b/src/Teapot.Web/Models/TeapotStatusCodeMetadataCollection.cs @@ -4,34 +4,34 @@ namespace Teapot.Web.Models; -public class TeapotStatusCodeResults : Dictionary +public class TeapotStatusCodeMetadataCollection : Dictionary { - public TeapotStatusCodeResults( - AmazonStatusCodeResults amazon, - CloudflareStatusCodeResults cloudflare, - EsriStatusCodeResults esri, - LaravelStatusCodeResults laravel, - MicrosoftStatusCodeResults microsoft, - NginxStatusCodeResults nginx, - TwitterStatusCodeResults twitter) + public TeapotStatusCodeMetadataCollection( + AmazonStatusCodeMetadata amazon, + CloudflareStatusCodeMetadata cloudflare, + EsriStatusCodeMetadata esri, + LaravelStatusCodeMetadata laravel, + MicrosoftStatusCodeMetadata microsoft, + NginxStatusCodeMetadata nginx, + TwitterStatusCodeMetadata twitter) { // 1xx range - Add(100, new TeapotStatusCodeResult + Add(100, new TeapotStatusCodeMetadata { Description = "Continue", ExcludeBody = true }); - Add(101, new TeapotStatusCodeResult + Add(101, new TeapotStatusCodeMetadata { Description = "Switching Protocols", ExcludeBody = true }); - Add(102, new TeapotStatusCodeResult + Add(102, new TeapotStatusCodeMetadata { Description = "Processing", ExcludeBody = true }); - Add(103, new TeapotStatusCodeResult + Add(103, new TeapotStatusCodeMetadata { Description = "Early Hints", ExcludeBody = true, @@ -42,33 +42,33 @@ public TeapotStatusCodeResults( }); // 2xx range - Add(200, new TeapotStatusCodeResult + Add(200, new TeapotStatusCodeMetadata { Description = "OK", }); - Add(201, new TeapotStatusCodeResult + Add(201, new TeapotStatusCodeMetadata { Description = "Created", }); - Add(202, new TeapotStatusCodeResult + Add(202, new TeapotStatusCodeMetadata { Description = "Accepted" }); - Add(203, new TeapotStatusCodeResult + Add(203, new TeapotStatusCodeMetadata { Description = "Non-Authoritative Information" }); - Add(204, new TeapotStatusCodeResult + Add(204, new TeapotStatusCodeMetadata { Description = "No Content", ExcludeBody = true }); - Add(205, new TeapotStatusCodeResult + Add(205, new TeapotStatusCodeMetadata { Description = "Reset Content", ExcludeBody = true }); - Add(206, new TeapotStatusCodeResult + Add(206, new TeapotStatusCodeMetadata { Description = "Partial Content", IncludeHeaders = new Dictionary @@ -76,7 +76,7 @@ public TeapotStatusCodeResults( {"Content-Range", "0-30"} } }); - Add(207, new TeapotStatusCodeResult + Add(207, new TeapotStatusCodeMetadata { Description = "Multi-Status", IncludeHeaders = new Dictionary @@ -84,31 +84,33 @@ public TeapotStatusCodeResults( { "Content-Type", "application/xml; charset=\"utf-8\"" } }, Link = new Uri("https://tools.ietf.org/html/rfc4918"), - Body = @" - - - http://www.example.com/container/resource3 - HTTP/1.1 423 Locked - - -" - }); - Add(208, new TeapotStatusCodeResult + Body = """ + + + + http://www.example.com/container/resource3 + HTTP/1.1 423 Locked + + + + """ + }); + Add(208, new TeapotStatusCodeMetadata { Description = "Already Reported" }); - Add(226, new TeapotStatusCodeResult + Add(226, new TeapotStatusCodeMetadata { Description = "IM Used", Link = new Uri("https://tools.ietf.org/html/rfc3229#section-10.4.1") }); // 3xx range - Add(300, new TeapotStatusCodeResult + Add(300, new TeapotStatusCodeMetadata { Description = "Multiple Choices" }); - Add(301, new TeapotStatusCodeResult + Add(301, new TeapotStatusCodeMetadata { Description = "Moved Permanently", IncludeHeaders = new Dictionary @@ -116,7 +118,7 @@ public TeapotStatusCodeResults( {"Location", "https://httpstat.us"} } }); - Add(302, new TeapotStatusCodeResult + Add(302, new TeapotStatusCodeMetadata { Description = "Found", IncludeHeaders = new Dictionary @@ -124,7 +126,7 @@ public TeapotStatusCodeResults( {"Location", "https://httpstat.us"} } }); - Add(303, new TeapotStatusCodeResult + Add(303, new TeapotStatusCodeMetadata { Description = "See Other", IncludeHeaders = new Dictionary @@ -132,12 +134,12 @@ public TeapotStatusCodeResults( {"Location", "https://httpstat.us"} } }); - Add(304, new TeapotStatusCodeResult + Add(304, new TeapotStatusCodeMetadata { Description = "Not Modified", ExcludeBody = true }); - Add(305, new TeapotStatusCodeResult + Add(305, new TeapotStatusCodeMetadata { Description = "Use Proxy", IncludeHeaders = new Dictionary @@ -145,11 +147,11 @@ public TeapotStatusCodeResults( {"Location", "https://httpstat.us"} } }); - Add(306, new TeapotStatusCodeResult + Add(306, new TeapotStatusCodeMetadata { Description = "Switch Proxy" }); - Add(307, new TeapotStatusCodeResult + Add(307, new TeapotStatusCodeMetadata { Description = "Temporary Redirect", IncludeHeaders = new Dictionary @@ -157,7 +159,7 @@ public TeapotStatusCodeResults( {"Location", "https://httpstat.us"} } }); - Add(308, new TeapotStatusCodeResult + Add(308, new TeapotStatusCodeMetadata { Description = "Permanent Redirect", IncludeHeaders = new Dictionary @@ -167,11 +169,11 @@ public TeapotStatusCodeResults( }); // 4xx - Add(400, new TeapotStatusCodeResult + Add(400, new TeapotStatusCodeMetadata { Description = "Bad Request" }); - Add(401, new TeapotStatusCodeResult + Add(401, new TeapotStatusCodeMetadata { Description = "Unauthorized", IncludeHeaders = new Dictionary @@ -179,27 +181,27 @@ public TeapotStatusCodeResults( {"WWW-Authenticate", "Basic realm=\"Fake Realm\""} } }); - Add(402, new TeapotStatusCodeResult + Add(402, new TeapotStatusCodeMetadata { Description = "Payment Required" }); - Add(403, new TeapotStatusCodeResult + Add(403, new TeapotStatusCodeMetadata { Description = "Forbidden" }); - Add(404, new TeapotStatusCodeResult + Add(404, new TeapotStatusCodeMetadata { Description = "Not Found" }); - Add(405, new TeapotStatusCodeResult + Add(405, new TeapotStatusCodeMetadata { Description = "Method Not Allowed" }); - Add(406, new TeapotStatusCodeResult + Add(406, new TeapotStatusCodeMetadata { Description = "Not Acceptable" }); - Add(407, new TeapotStatusCodeResult + Add(407, new TeapotStatusCodeMetadata { Description = "Proxy Authentication Required", IncludeHeaders = new Dictionary @@ -207,80 +209,80 @@ public TeapotStatusCodeResults( {"Proxy-Authenticate", "Basic realm=\"Fake Realm\""} } }); - Add(408, new TeapotStatusCodeResult + Add(408, new TeapotStatusCodeMetadata { Description = "Request Timeout" }); - Add(409, new TeapotStatusCodeResult + Add(409, new TeapotStatusCodeMetadata { Description = "Conflict" }); - Add(410, new TeapotStatusCodeResult + Add(410, new TeapotStatusCodeMetadata { Description = "Gone" }); - Add(411, new TeapotStatusCodeResult + Add(411, new TeapotStatusCodeMetadata { Description = "Length Required" }); - Add(412, new TeapotStatusCodeResult + Add(412, new TeapotStatusCodeMetadata { Description = "Precondition Failed" }); - Add(413, new TeapotStatusCodeResult + Add(413, new TeapotStatusCodeMetadata { Description = "Request Entity Too Large" }); - Add(414, new TeapotStatusCodeResult + Add(414, new TeapotStatusCodeMetadata { Description = "Request-URI Too Long" }); - Add(415, new TeapotStatusCodeResult + Add(415, new TeapotStatusCodeMetadata { Description = "Unsupported Media Type" }); - Add(416, new TeapotStatusCodeResult + Add(416, new TeapotStatusCodeMetadata { Description = "Requested Range Not Satisfiable" }); - Add(417, new TeapotStatusCodeResult + Add(417, new TeapotStatusCodeMetadata { Description = "Expectation Failed" }); - Add(418, new TeapotStatusCodeResult + Add(418, new TeapotStatusCodeMetadata { Description = "I'm a teapot", Link = new Uri("https://www.ietf.org/rfc/rfc2324.txt") }); - Add(421, new TeapotStatusCodeResult + Add(421, new TeapotStatusCodeMetadata { Description = "Misdirected Request" }); - Add(422, new TeapotStatusCodeResult + Add(422, new TeapotStatusCodeMetadata { Description = "Unprocessable Entity" }); - Add(423, new TeapotStatusCodeResult + Add(423, new TeapotStatusCodeMetadata { Description = "Locked" }); - Add(424, new TeapotStatusCodeResult + Add(424, new TeapotStatusCodeMetadata { Description = "Failed Dependency" }); - Add(425, new TeapotStatusCodeResult + Add(425, new TeapotStatusCodeMetadata { Description = "Too Early" }); - Add(426, new TeapotStatusCodeResult + Add(426, new TeapotStatusCodeMetadata { Description = "Upgrade Required" }); - Add(428, new TeapotStatusCodeResult + Add(428, new TeapotStatusCodeMetadata { Description = "Precondition Required" }); - Add(429, new TeapotStatusCodeResult + Add(429, new TeapotStatusCodeMetadata { Description = "Too Many Requests", IncludeHeaders = new Dictionary @@ -288,59 +290,59 @@ public TeapotStatusCodeResults( {"Retry-After", "5"} } }); - Add(431, new TeapotStatusCodeResult + Add(431, new TeapotStatusCodeMetadata { Description = "Request Header Fields Too Large" }); - Add(451, new TeapotStatusCodeResult + Add(451, new TeapotStatusCodeMetadata { Description = "Unavailable For Legal Reasons" }); // 5xx - Add(500, new TeapotStatusCodeResult + Add(500, new TeapotStatusCodeMetadata { Description = "Internal Server Error" }); - Add(501, new TeapotStatusCodeResult + Add(501, new TeapotStatusCodeMetadata { Description = "Not Implemented" }); - Add(502, new TeapotStatusCodeResult + Add(502, new TeapotStatusCodeMetadata { Description = "Bad Gateway" }); - Add(503, new TeapotStatusCodeResult + Add(503, new TeapotStatusCodeMetadata { Description = "Service Unavailable" }); - Add(504, new TeapotStatusCodeResult + Add(504, new TeapotStatusCodeMetadata { Description = "Gateway Timeout" }); - Add(505, new TeapotStatusCodeResult + Add(505, new TeapotStatusCodeMetadata { Description = "HTTP Version Not Supported" }); - Add(506, new TeapotStatusCodeResult + Add(506, new TeapotStatusCodeMetadata { Description = "Variant Also Negotiates" }); - Add(507, new TeapotStatusCodeResult + Add(507, new TeapotStatusCodeMetadata { Description = "Insufficient Storage" }); - Add(508, new TeapotStatusCodeResult + Add(508, new TeapotStatusCodeMetadata { Description = "Loop Detected", Link = new Uri("https://tools.ietf.org/html/rfc5842") }); - Add(510, new TeapotStatusCodeResult + Add(510, new TeapotStatusCodeMetadata { Description = "Not Extended", Link = new Uri("https://tools.ietf.org/html/rfc2774") }); - Add(511, new TeapotStatusCodeResult + Add(511, new TeapotStatusCodeMetadata { Description = "Network Authentication Required" }); @@ -354,7 +356,7 @@ public TeapotStatusCodeResults( AddNonStandardStatusCodes(twitter); } - private void AddNonStandardStatusCodes(IDictionary codes) + private void AddNonStandardStatusCodes(IDictionary codes) { foreach (var item in codes) { diff --git a/src/Teapot.Web/Models/Unofficial/AmazonStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/AmazonStatusCodeMetadata.cs similarity index 67% rename from src/Teapot.Web/Models/Unofficial/AmazonStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/AmazonStatusCodeMetadata.cs index 9b4999d..79209ab 100644 --- a/src/Teapot.Web/Models/Unofficial/AmazonStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/AmazonStatusCodeMetadata.cs @@ -2,23 +2,23 @@ namespace Teapot.Web.Models.Unofficial; -public class AmazonStatusCodeResults : Dictionary +public class AmazonStatusCodeMetadata : Dictionary { - public AmazonStatusCodeResults() + public AmazonStatusCodeMetadata() { - Add(460, new TeapotStatusCodeResult + Add(460, new TeapotStatusCodeMetadata { Description = "Client closed the connection with AWS Elastic Load Balancer", IsNonStandard = true, }); - Add(463, new TeapotStatusCodeResult + Add(463, new TeapotStatusCodeMetadata { Description = "The load balancer received an X-Forwarded-For request header with more than 30 IP addresses", IsNonStandard = true, }); - Add(561, new TeapotStatusCodeResult + Add(561, new TeapotStatusCodeMetadata { Description = "Unauthorized (AWS Elastic Load Balancer)", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeMetadata.cs similarity index 66% rename from src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeMetadata.cs index 65b5665..6d43fb8 100644 --- a/src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/CloudflareStatusCodeMetadata.cs @@ -2,51 +2,51 @@ namespace Teapot.Web.Models.Unofficial; -public class CloudflareStatusCodeResults : Dictionary +public class CloudflareStatusCodeMetadata : Dictionary { - public CloudflareStatusCodeResults() + public CloudflareStatusCodeMetadata() { - Add(520, new TeapotStatusCodeResult + Add(520, new TeapotStatusCodeMetadata { Description = "Web Server Returned an Unknown Error", IsNonStandard = true, }); - Add(521, new TeapotStatusCodeResult + Add(521, new TeapotStatusCodeMetadata { Description = "Web Server Is Down", IsNonStandard = true, }); - Add(522, new TeapotStatusCodeResult + Add(522, new TeapotStatusCodeMetadata { Description = "Connection Timed out", IsNonStandard = true, }); - Add(523, new TeapotStatusCodeResult + Add(523, new TeapotStatusCodeMetadata { Description = "Origin Is Unreachable", IsNonStandard = true, }); - Add(524, new TeapotStatusCodeResult + Add(524, new TeapotStatusCodeMetadata { Description = "A Timeout Occurred", IsNonStandard = true, }); - Add(525, new TeapotStatusCodeResult + Add(525, new TeapotStatusCodeMetadata { Description = "SSL Handshake Failed", IsNonStandard = true, }); - Add(526, new TeapotStatusCodeResult + Add(526, new TeapotStatusCodeMetadata { Description = "Invalid SSL Certificate", IsNonStandard = true, }); - Add(527, new TeapotStatusCodeResult + Add(527, new TeapotStatusCodeMetadata { Description = "Railgun Error", IsNonStandard = true, }); - Add(530, new TeapotStatusCodeResult + Add(530, new TeapotStatusCodeMetadata { Description = "Origin DNS Error", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/EsriStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/EsriStatusCodeMetadata.cs similarity index 55% rename from src/Teapot.Web/Models/Unofficial/EsriStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/EsriStatusCodeMetadata.cs index e5de25b..04cb18a 100644 --- a/src/Teapot.Web/Models/Unofficial/EsriStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/EsriStatusCodeMetadata.cs @@ -2,11 +2,11 @@ namespace Teapot.Web.Models.Unofficial; -public class EsriStatusCodeResults : Dictionary +public class EsriStatusCodeMetadata : Dictionary { - public EsriStatusCodeResults() + public EsriStatusCodeMetadata() { - Add(498, new TeapotStatusCodeResult + Add(498, new TeapotStatusCodeMetadata { Description = "Invalid Token (Esri)", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/LaravelStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/LaravelStatusCodeMetadata.cs similarity index 55% rename from src/Teapot.Web/Models/Unofficial/LaravelStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/LaravelStatusCodeMetadata.cs index bd1f21a..e2175b9 100644 --- a/src/Teapot.Web/Models/Unofficial/LaravelStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/LaravelStatusCodeMetadata.cs @@ -2,11 +2,11 @@ namespace Teapot.Web.Models.Unofficial; -public class LaravelStatusCodeResults : Dictionary +public class LaravelStatusCodeMetadata : Dictionary { - public LaravelStatusCodeResults() + public LaravelStatusCodeMetadata() { - Add(419, new TeapotStatusCodeResult + Add(419, new TeapotStatusCodeMetadata { Description = "CSRF Token Missing or Expired", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeMetadata.cs similarity index 61% rename from src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeMetadata.cs index 208ad18..52bb93a 100644 --- a/src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/MicrosoftStatusCodeMetadata.cs @@ -2,23 +2,23 @@ namespace Teapot.Web.Models.Unofficial; -public class MicrosoftStatusCodeResults : Dictionary +public class MicrosoftStatusCodeMetadata : Dictionary { - public MicrosoftStatusCodeResults() + public MicrosoftStatusCodeMetadata() { - Add(440, new TeapotStatusCodeResult + Add(440, new TeapotStatusCodeMetadata { Description = "Login Time-out", IsNonStandard = true, }); - Add(449, new TeapotStatusCodeResult + Add(449, new TeapotStatusCodeMetadata { Description = "Retry With", IsNonStandard = true, }); - Add(450, new TeapotStatusCodeResult + Add(450, new TeapotStatusCodeMetadata { Description = "Blocked by Windows Parental Controls", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/NginxStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/NginxStatusCodeMetadata.cs similarity index 65% rename from src/Teapot.Web/Models/Unofficial/NginxStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/NginxStatusCodeMetadata.cs index 2a9bb18..247ef36 100644 --- a/src/Teapot.Web/Models/Unofficial/NginxStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/NginxStatusCodeMetadata.cs @@ -2,41 +2,41 @@ namespace Teapot.Web.Models.Unofficial; -public class NginxStatusCodeResults : Dictionary +public class NginxStatusCodeMetadata : Dictionary { - public NginxStatusCodeResults() + public NginxStatusCodeMetadata() { - Add(444, new TeapotStatusCodeResult + Add(444, new TeapotStatusCodeMetadata { Description = "No Response", IsNonStandard = true, }); - Add(494, new TeapotStatusCodeResult + Add(494, new TeapotStatusCodeMetadata { Description = "Request header too large", IsNonStandard = true, }); - Add(495, new TeapotStatusCodeResult + Add(495, new TeapotStatusCodeMetadata { Description = "SSL Certificate Error", IsNonStandard = true, }); - Add(496, new TeapotStatusCodeResult + Add(496, new TeapotStatusCodeMetadata { Description = "SSL Certificate Required", IsNonStandard = true, }); - Add(497, new TeapotStatusCodeResult + Add(497, new TeapotStatusCodeMetadata { Description = "HTTP Request Sent to HTTPS Port", IsNonStandard = true, }); - Add(499, new TeapotStatusCodeResult + Add(499, new TeapotStatusCodeMetadata { Description = "Client Closed Request", IsNonStandard = true, diff --git a/src/Teapot.Web/Models/Unofficial/TwitterStatusCodeResults.cs b/src/Teapot.Web/Models/Unofficial/TwitterStatusCodeMetadata.cs similarity index 53% rename from src/Teapot.Web/Models/Unofficial/TwitterStatusCodeResults.cs rename to src/Teapot.Web/Models/Unofficial/TwitterStatusCodeMetadata.cs index 74beec7..effd95c 100644 --- a/src/Teapot.Web/Models/Unofficial/TwitterStatusCodeResults.cs +++ b/src/Teapot.Web/Models/Unofficial/TwitterStatusCodeMetadata.cs @@ -2,11 +2,11 @@ namespace Teapot.Web.Models.Unofficial; -public class TwitterStatusCodeResults : Dictionary +public class TwitterStatusCodeMetadata : Dictionary { - public TwitterStatusCodeResults() + public TwitterStatusCodeMetadata() { - Add(420, new TeapotStatusCodeResult + Add(420, new TeapotStatusCodeMetadata { Description = "Enhance Your Calm", IsNonStandard = true, diff --git a/src/Teapot.Web/Views/Status/Index.cshtml b/src/Teapot.Web/Pages/Index.cshtml similarity index 82% rename from src/Teapot.Web/Views/Status/Index.cshtml rename to src/Teapot.Web/Pages/Index.cshtml index d94b469..3a2b3c0 100644 --- a/src/Teapot.Web/Views/Status/Index.cshtml +++ b/src/Teapot.Web/Pages/Index.cshtml @@ -1,5 +1,7 @@ -@using Teapot.Web.Controllers; -@model Teapot.Web.Models.TeapotStatusCodeResults +@page +@using Teapot.Web.Models; +@inject TeapotStatusCodeMetadataCollection StatusCodes +

This is a super simple service for generating different HTTP codes.

It's useful for testing how your own scripts deal with varying responses.

@@ -30,25 +32,28 @@

- If you want a delay on the response add a query string or provide a header of @StatusController.SLEEP_HEADER for the sleep duration (the time in ms, max 5 minutes*), like this: + If you want a delay on the response add a query string or provide a header of @StatusExtensions.SLEEP_HEADER for the sleep duration (the time in ms, max 5 minutes*), like this: @Html.RouteLink("httpstat.us/200?sleep=5000", "StatusCode", new {statusCode = 200, sleep = 5000})
*When using the hosted instance the timeout is actually 230 seconds, which is the max timeout allowed by an Azure App Service (see this thread post). If you host it yourself expect the limits to be different.

- If you want to return additional headers to the client, you can send them in the request with the @StatusController.CUSTOM_RESPONSE_HEADER_PREFIX prefix. A header of @(StatusController.CUSTOM_RESPONSE_HEADER_PREFIX)Foo: Bar will append the Foo: Bar header in the response. + If you want to return additional headers to the client, you can send them in the request with the @StatusExtensions.CUSTOM_RESPONSE_HEADER_PREFIX prefix. A header of @(StatusExtensions.CUSTOM_RESPONSE_HEADER_PREFIX)Foo: Bar will append the Foo: Bar header in the response.

Here are all the codes we support (and any special notes):

- @foreach ((int statusCode, TeapotStatusCodeResult result) in Model.OrderBy(x => x.Value.IsNonStandard).ThenBy(x => x.Key)) { -
@Html.RouteLink(statusCode.ToString(), "StatusCode", new { statusCode })
+ @foreach ((int statusCode, TeapotStatusCodeMetadata result) in StatusCodes.OrderBy(x => x.Value.IsNonStandard).ThenBy(x => x.Key)) + { +
@statusCode
- @if (result.Link is not null) { + @if (result.Link is not null) + { @result.Description@(result.IsNonStandard ? " (non-standard status code)" : "") } - else { + else + { @result.Description @(result.IsNonStandard ? " (non-standard status code)" : "") diff --git a/src/Teapot.Web/Views/Shared/Error.cshtml b/src/Teapot.Web/Pages/Shared/Error.cshtml similarity index 100% rename from src/Teapot.Web/Views/Shared/Error.cshtml rename to src/Teapot.Web/Pages/Shared/Error.cshtml diff --git a/src/Teapot.Web/Views/Shared/_Layout.cshtml b/src/Teapot.Web/Pages/Shared/_Layout.cshtml similarity index 100% rename from src/Teapot.Web/Views/Shared/_Layout.cshtml rename to src/Teapot.Web/Pages/Shared/_Layout.cshtml diff --git a/src/Teapot.Web/Views/Teapot/Index.cshtml b/src/Teapot.Web/Pages/Teapot.cshtml similarity index 93% rename from src/Teapot.Web/Views/Teapot/Index.cshtml rename to src/Teapot.Web/Pages/Teapot.cshtml index 8258a81..765f6c1 100644 --- a/src/Teapot.Web/Views/Teapot/Index.cshtml +++ b/src/Teapot.Web/Pages/Teapot.cshtml @@ -1,4 +1,5 @@ -

+@page +

What you've requested doesn't exist, but here's a nice teapot for you to look at instead. :)

diff --git a/src/Teapot.Web/Views/_ViewImports.cshtml b/src/Teapot.Web/Pages/_ViewImports.cshtml similarity index 100% rename from src/Teapot.Web/Views/_ViewImports.cshtml rename to src/Teapot.Web/Pages/_ViewImports.cshtml diff --git a/src/Teapot.Web/Views/_ViewStart.cshtml b/src/Teapot.Web/Pages/_ViewStart.cshtml similarity index 100% rename from src/Teapot.Web/Views/_ViewStart.cshtml rename to src/Teapot.Web/Pages/_ViewStart.cshtml diff --git a/src/Teapot.Web/Program.cs b/src/Teapot.Web/Program.cs index 9f771f8..825667e 100644 --- a/src/Teapot.Web/Program.cs +++ b/src/Teapot.Web/Program.cs @@ -2,24 +2,30 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Teapot.Web; using Teapot.Web.Models; using Teapot.Web.Models.Unofficial; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddRazorPages(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(); builder.Services.AddApplicationInsightsTelemetry(); -builder.Services.AddControllersWithViews(); -var app = builder.Build(); +builder.Services.AddFairUseRateLimiter(); + +builder.Services.AddCors(); + +WebApplication app = builder.Build(); if (app.Environment.IsDevelopment()) { @@ -41,19 +47,21 @@ .AllowAnyHeader() .AllowAnyOrigin() .AllowAnyMethod() - .WithExposedHeaders(new[] - { - "Link", // 103 - "Content-Range", // 206 - "Location", // 301, 302, 303, 305, 307, 308 - "WWW-Authenticate", // 401 - "Proxy-Authenticate", // 407 - "Retry-After" // 429 - }); + .WithExposedHeaders( + [ + "Link", // 103 + "Content-Range", // 206 + "Location", // 301, 302, 303, 305, 307, 308 + "WWW-Authenticate", // 401 + "Proxy-Authenticate", // 407 + "Retry-After" // 429 + ]); }); -app.MapControllerRoute( - name: "default", - pattern: "{controller=Teapot}/{action=teapot}"); +app.UseFairUseRateLimiter(); + +app.MapStatusEndpoints(FairUseRateLimiterExtensions.PolicyName); + +app.MapRazorPages(); app.Run(); diff --git a/src/Teapot.Web/StatusExtensions.cs b/src/Teapot.Web/StatusExtensions.cs new file mode 100644 index 0000000..bf1570f --- /dev/null +++ b/src/Teapot.Web/StatusExtensions.cs @@ -0,0 +1,122 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Teapot.Web.Models; + +namespace Teapot.Web; + +internal static class StatusExtensions +{ + public const string SLEEP_HEADER = "X-HttpStatus-Sleep"; + public const string SUPPRESS_BODY_HEADER = "X-HttpStatus-SuppressBody"; + public const string CUSTOM_RESPONSE_HEADER_PREFIX = "X-HttpStatus-Response-"; + + private static readonly string[] httpMethods = ["Get", "Put", "Post", "Delete", "Head", "Options", "Trace", "Patch"]; + + internal static WebApplication MapStatusEndpoints(this WebApplication app, string policyName) + { + app.MapMethods("/{status:int}", httpMethods, HandleStatusRequestAsync) + .RequireRateLimiting(policyName); + app.MapMethods("/{status:int}/{*wildcard}", httpMethods, HandleStatusRequestAsync) + .RequireRateLimiting(policyName); + + app.MapMethods("/random/{range}", httpMethods, HandleRandomRequest) + .RequireRateLimiting(policyName); + app.MapMethods("/random/{range}/{*wildcard}", httpMethods, HandleRandomRequest) + .RequireRateLimiting(policyName); + + app.MapGet("im-a-teapot", () => TypedResults.Redirect("https://www.ietf.org/rfc/rfc2324.txt")); + + return app; + } + + internal static IResult HandleStatusRequestAsync( + int status, + int? sleep, + bool? suppressBody, + string? wildcard, + HttpRequest req, + [FromServices] TeapotStatusCodeMetadataCollection statusCodes) + { + TeapotStatusCodeMetadata statusData = statusCodes.TryGetValue(status, out TeapotStatusCodeMetadata? value) ? + value : + new TeapotStatusCodeMetadata { Description = $"{status} Unknown Code" }; + sleep ??= FindSleepInHeader(req); + suppressBody ??= FindSuppressBodyInHeader(req); + + Dictionary customResponseHeaders = req.Headers + .Where(header => header.Key.StartsWith(CUSTOM_RESPONSE_HEADER_PREFIX)) + .ToDictionary( + header => header.Key.Replace(CUSTOM_RESPONSE_HEADER_PREFIX, string.Empty), + header => header.Value); + + return new CustomHttpStatusCodeResult(status, statusData, sleep, suppressBody, customResponseHeaders); + } + + internal static IResult HandleRandomRequest( + HttpRequest req, + [FromServices] TeapotStatusCodeMetadataCollection statusCodes, + int? sleep, + bool? suppressBody, + string? wildcard, + string range = "100-599") + { + try + { + int statusCode = GetRandomStatus(range); + return HandleStatusRequestAsync(statusCode, sleep, suppressBody, wildcard, req, statusCodes); + } + catch + { + return TypedResults.BadRequest(); + } + } + + private static int? FindSleepInHeader(HttpRequest req) + { + if (req.Headers.TryGetValue(SLEEP_HEADER, out StringValues sleepHeader) && sleepHeader.Count == 1 && sleepHeader[0] is not null) + { + string? val = sleepHeader[0]; + if (int.TryParse(val, out int sleepFromHeader)) + { + return sleepFromHeader; + } + } + + return null; + } + + private static bool? FindSuppressBodyInHeader(HttpRequest req) + { + if (req.Headers.TryGetValue(SUPPRESS_BODY_HEADER, out StringValues suppressBodyHeader) && suppressBodyHeader.Count == 1 && suppressBodyHeader[0] is not null) + { + string? val = suppressBodyHeader[0]; + if (bool.TryParse(val, out bool suppressBodyFromHeader)) + { + return suppressBodyFromHeader; + } + } + + return null; + } + + private static int GetRandomStatus(string range) + { + // copied from https://stackoverflow.com/a/37213725/260221 + int[] options = range.Split(',') + .Select(x => x.Split('-')) + .Select(p => new { First = int.Parse(p.First()), Last = int.Parse(p.Last()) }) + .SelectMany(x => Enumerable.Range(x.First, x.Last - x.First + 1)) + .ToArray(); + + return options[new Random().Next(options.Length)]; + } +} diff --git a/src/Teapot.Web/Teapot.Web.csproj b/src/Teapot.Web/Teapot.Web.csproj index b8a12f8..c667de2 100644 --- a/src/Teapot.Web/Teapot.Web.csproj +++ b/src/Teapot.Web/Teapot.Web.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 enable 26052284-5a8d-4e62-83dc-7e394f7fa038 Linux @@ -9,8 +9,8 @@ - - + +