feat(inbound-api): machine-readable error codes per spec (incl. SITE_UNREACHABLE) + 415 for non-JSON bodies

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:29:55 -04:00
parent 761729b5d0
commit b5e80d4c00
8 changed files with 248 additions and 30 deletions
@@ -92,6 +92,59 @@ public sealed class EndpointContentTypeTests : IDisposable
Assert.Contains("42", body);
}
[Fact]
public async Task NonJsonContentType_WithBody_Returns415()
{
// Task 5 (S9): a body with a non-JSON Content-Type must be rejected with 415 +
// UNSUPPORTED_MEDIA_TYPE rather than a misleading 400 "invalid JSON". A body
// with NO Content-Type header is still parsed leniently as JSON (preserving
// existing callers).
const string methodName = "echo415";
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
{
Id = 1,
TimeoutSeconds = 10,
ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
};
var repo = Substitute.For<IInboundApiRepository>();
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
.Returns(method);
using var host = await BuildHostAsync(repo);
var token = await SeedKeyAsync(host, methodName);
var client = host.GetTestClient();
// (a) text/plain body → 415 UNSUPPORTED_MEDIA_TYPE
var nonJson = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("hello")),
};
nonJson.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain");
nonJson.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var nonJsonResponse = await client.SendAsync(nonJson);
var nonJsonBody = await nonJsonResponse.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.UnsupportedMediaType, nonJsonResponse.StatusCode);
Assert.Contains("UNSUPPORTED_MEDIA_TYPE", nonJsonBody);
// (b) body with NO Content-Type header → still parses as JSON (lenient), 200.
var noContentType = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}")),
};
noContentType.Content.Headers.ContentType = null;
noContentType.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var lenientResponse = await client.SendAsync(noContentType);
var lenientBody = await lenientResponse.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, lenientResponse.StatusCode);
Assert.Contains("42", lenientBody);
}
/// <summary>Seeds a key scoped for <paramref name="methodName"/> and returns its Bearer token.</summary>
private static async Task<string> SeedKeyAsync(IHost host, string methodName)
{