diff --git a/src/ByteBard.AsyncAPI.Bindings/Http/HttpMessageBinding.cs b/src/ByteBard.AsyncAPI.Bindings/Http/HttpMessageBinding.cs index 5a76874..0a8fc0f 100644 --- a/src/ByteBard.AsyncAPI.Bindings/Http/HttpMessageBinding.cs +++ b/src/ByteBard.AsyncAPI.Bindings/Http/HttpMessageBinding.cs @@ -1,7 +1,6 @@ namespace ByteBard.AsyncAPI.Bindings.Http { using System; - using System.Net; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Readers; using ByteBard.AsyncAPI.Readers.ParseNodes; @@ -25,9 +24,10 @@ public class HttpMessageBinding : MessageBinding /// /// The HTTP response status code according to RFC 9110. `statusCode` is only relevant for messages referenced by the Operation Reply Object. + /// Stored as an integer so that codes without a member (e.g. 425, 499, 520) are represented as-is. /// Note: This field is only serialized in AsyncAPI V3. /// - public HttpStatusCode? StatusCode { get; set; } + public int? StatusCode { get; set; } public override string BindingKey => "http"; @@ -35,16 +35,26 @@ public class HttpMessageBinding : MessageBinding { { "bindingVersion", (a, n) => { a.BindingVersion = n.GetScalarValue(); } }, { "headers", (a, n) => { a.Headers = AsyncApiJsonSchemaDeserializer.LoadSchema(n); } }, - { "statusCode", (a, n) => - { - if (int.TryParse(n.GetScalarValue(), out var code)) - { - a.StatusCode = (HttpStatusCode)code; - } - } - }, + { "statusCode", (a, n) => { a.StatusCode = LoadStatusCode(n); } }, }; + /// + /// Reads the status code, reporting values outside the RFC 9110 range (100-599) as errors. + /// + /// An out-of-range value is still kept so the document can be written back unchanged. + private static int LoadStatusCode(ParseNode node) + { + var code = node.GetIntegerValue(); + if (code < 100 || code > 599) + { + node.Context.Diagnostic.Errors.Add(new AsyncApiError( + node.Context.GetLocation(), + $"statusCode {code} is not a valid HTTP status code. It must be between 100 and 599.")); + } + + return code; + } + public override void SerializeV2(IAsyncApiWriter writer) { if (writer is null) @@ -71,7 +81,7 @@ public override void SerializeV3(IAsyncApiWriter writer) if (this.StatusCode.HasValue) { - writer.WriteRequiredProperty(AsyncApiConstants.StatusCode, (int)this.StatusCode.Value); + writer.WriteRequiredProperty(AsyncApiConstants.StatusCode, this.StatusCode.Value); } writer.WriteOptionalProperty(AsyncApiConstants.BindingVersion, this.BindingVersion ?? V3BindingVersion); diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/Http/HttpBindings_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/Http/HttpBindings_Should.cs index b66eb67..f1b25ef 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/Http/HttpBindings_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/Http/HttpBindings_Should.cs @@ -1,7 +1,6 @@ namespace ByteBard.AsyncAPI.Tests.Bindings.Http { using System.Linq; - using System.Net; using FluentAssertions; using ByteBard.AsyncAPI.Bindings; using ByteBard.AsyncAPI.Bindings.Http; @@ -156,7 +155,7 @@ public void V3_HttpMessageBinding_IncludesStatusCode() { Description = "response headers", }, - StatusCode = HttpStatusCode.OK, + StatusCode = 200, }); // Act @@ -179,7 +178,7 @@ public void V2_HttpMessageBinding_OmitsStatusCode() { Description = "response headers", }, - StatusCode = HttpStatusCode.OK, + StatusCode = 200, }); // Act @@ -189,5 +188,79 @@ public void V2_HttpMessageBinding_OmitsStatusCode() actual.Should().NotContain("statusCode"); actual.Should().Contain("bindingVersion: 0.2.0"); } + + [Test] + public void V3_HttpMessageBinding_ReadsStatusCode() + { + var yaml = + """ + bindings: + http: + statusCode: 404 + """; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.Http }; + var message = new AsyncApiStringReader(settings).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().BeEmpty(); + ((HttpMessageBinding)message.Bindings["http"]).StatusCode.Should().Be(404); + } + + [TestCase("not-found")] + [TestCase("200.5")] + public void V3_HttpMessageBinding_WithInvalidStatusCode_ReportsError(string statusCode) + { + var yaml = + "bindings:\n" + + " http:\n" + + " statusCode: " + statusCode + "\n"; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.Http }; + new AsyncApiStringReader(settings).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain("could not parse to integer"); + } + + [TestCase(100)] + [TestCase(599)] + [TestCase(425)] // Too Early (RFC 8470), not a System.Net.HttpStatusCode member + [TestCase(499)] // nginx client closed request + [TestCase(520)] // Cloudflare unknown error + public void V3_HttpMessageBinding_WithValidStatusCode_ReportsNoErrorAndRoundTrips(int statusCode) + { + var yaml = + "bindings:\n" + + " http:\n" + + " statusCode: " + statusCode + "\n"; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.Http }; + var message = new AsyncApiStringReader(settings).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().BeEmpty(); + ((HttpMessageBinding)message.Bindings["http"]).StatusCode.Should().Be(statusCode); + message.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().Contain("statusCode: " + statusCode); + } + + [TestCase(0)] + [TestCase(42)] + [TestCase(99)] + [TestCase(600)] + [TestCase(-200)] + public void V3_HttpMessageBinding_WithOutOfRangeStatusCode_ReportsErrorAndKeepsValue(int statusCode) + { + var yaml = + "bindings:\n" + + " http:\n" + + " statusCode: " + statusCode + "\n"; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.Http }; + var message = new AsyncApiStringReader(settings).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + var error = diagnostics.Errors.Should().ContainSingle().Subject; + error.Message.Should().Contain("must be between 100 and 599"); + error.Pointer.Should().EndWith("statusCode"); + ((HttpMessageBinding)message.Bindings["http"]).StatusCode.Should().Be(statusCode); + message.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().Contain("statusCode: " + statusCode); + } } }