Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Text.Json;
using Alba;
using Shouldly;
using WolverineWebApi;

namespace Wolverine.Http.Tests;

public class content_negotiation_by_content_type : IntegrationContext
{
public content_negotiation_by_content_type(AppFixture fixture) : base(fixture)
{
}

[Fact]
public async Task route_to_v1_endpoint_by_content_type()
{
var body = JsonSerializer.Serialize(new CreateContentItemV1("Test Item"));

var result = await Scenario(x =>
{
x.Post
.Text(body)
.ToUrl("/content-negotiation/items");
x.WithRequestHeader("Content-Type", "application/vnd.item.v1+json");
x.StatusCodeShouldBe(200);
});

var response = result.ReadAsJson<ContentItemCreated>();
response.ShouldNotBeNull();
response.Name.ShouldBe("Test Item");
response.Version.ShouldBe("v1");
}

[Fact]
public async Task route_to_v2_endpoint_by_content_type()
{
var body = JsonSerializer.Serialize(new CreateContentItemV2("Test Item", "Widgets"));

var result = await Scenario(x =>
{
x.Post
.Text(body)
.ToUrl("/content-negotiation/items");
x.WithRequestHeader("Content-Type", "application/vnd.item.v2+json");
x.StatusCodeShouldBe(200);
});

var response = result.ReadAsJson<ContentItemCreated>();
response.ShouldNotBeNull();
response.Name.ShouldBe("Test Item");
response.Category.ShouldBe("Widgets");
response.Version.ShouldBe("v2");
}

[Fact]
public async Task content_type_matching_is_case_insensitive()
{
var body = JsonSerializer.Serialize(new CreateContentItemV1("Test Item"));

var result = await Scenario(x =>
{
x.Post
.Text(body)
.ToUrl("/content-negotiation/items");
x.WithRequestHeader("Content-Type", "Application/Vnd.Item.V1+json");
x.StatusCodeShouldBe(200);
});

var response = result.ReadAsJson<ContentItemCreated>();
response.ShouldNotBeNull();
response.Version.ShouldBe("v1");
}
}
23 changes: 23 additions & 0 deletions src/Http/Wolverine.Http/AcceptsContentTypeAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Wolverine.Http;

/// <summary>
/// Specify the accepted content types for this endpoint. When multiple endpoints share
/// the same route and HTTP method, Wolverine will use the Content-Type header to select
/// the correct endpoint. This enables API versioning via custom MIME types, e.g.
/// "application/vnd.myapp.v1+json".
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class AcceptsContentTypeAttribute : Attribute
{
public AcceptsContentTypeAttribute(params string[] contentTypes)
{
if (contentTypes.Length == 0)
{
throw new ArgumentException("At least one content type must be specified.", nameof(contentTypes));
}

ContentTypes = contentTypes;
}

public string[] ContentTypes { get; }
}
84 changes: 84 additions & 0 deletions src/Http/Wolverine.Http/ContentTypeEndpointSelectorPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Net.Http.Headers;

namespace Wolverine.Http;

/// <summary>
/// ASP.NET Core MatcherPolicy that filters candidate endpoints by the request's Content-Type
/// header when endpoints are decorated with <see cref="AcceptsContentTypeAttribute"/>.
/// </summary>
internal class ContentTypeEndpointSelectorPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
// Run after HttpMethodMatcherPolicy (order 0) and other built-in policies
public override int Order => 100;

public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
for (var i = 0; i < endpoints.Count; i++)
{
if (endpoints[i].Metadata.GetMetadata<AcceptsContentTypeAttribute>() != null)
{
return true;
}
}

return false;
}

public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
var requestContentType = httpContext.Request.ContentType;

for (var i = 0; i < candidates.Count; i++)
{
if (!candidates.IsValidCandidate(i))
{
continue;
}

var endpoint = candidates[i].Endpoint;
var acceptsAttribute = endpoint?.Metadata.GetMetadata<AcceptsContentTypeAttribute>();

if (acceptsAttribute == null)
{
// No attribute — leave this candidate valid as a fallback
continue;
}

if (string.IsNullOrEmpty(requestContentType))
{
// No Content-Type header but endpoint requires specific content type
candidates.SetValidity(i, false);
continue;
}

if (!IsContentTypeMatch(requestContentType, acceptsAttribute.ContentTypes))
{
candidates.SetValidity(i, false);
}
}

return Task.CompletedTask;
}

internal static bool IsContentTypeMatch(string requestContentType, string[] acceptedContentTypes)
{
// Parse the request content type to get the media type without parameters (charset, etc.)
if (MediaTypeHeaderValue.TryParse(requestContentType, out var parsedRequestType))
{
var requestMediaType = parsedRequestType.MediaType.Value;

for (var i = 0; i < acceptedContentTypes.Length; i++)
{
if (string.Equals(requestMediaType, acceptedContentTypes[i], StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}

return false;
}
}
10 changes: 9 additions & 1 deletion src/Http/Wolverine.Http/HttpChain.Codegen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ private string determineFileName()
char[] invalidPathChars = Path.GetInvalidPathChars();
var fileName = _httpMethods.Select(x => x.ToUpper()).Concat(parts).Join("_").Replace('-', '_').Replace("__", "_");

// Append content type suffix to make endpoint names unique when using [AcceptsContentType]
if (Method.Method.TryGetAttribute<AcceptsContentTypeAttribute>(out var acceptsAtt))
{
var suffix = acceptsAtt.ContentTypes[0]
.Replace("/", "_").Replace("+", "_").Replace(".", "_");
fileName = $"{fileName}_{suffix}";
}

var characters = fileName.ToCharArray();
for (int i = 0; i < characters.Length; i++)
{
Expand All @@ -199,7 +207,7 @@ private string determineFileName()
characters[i] = '_';
}
}

return new string(characters);
}
}
11 changes: 9 additions & 2 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,16 @@ private void applyMetadata()

if (HasRequestType)
{
if(IsFormData){
if (IsFormData)
{
Metadata.Accepts(RequestType, true, "application/x-www-form-urlencoded", "multipart/form-data");
}else{
}
else if (Method.Method.TryGetAttribute<AcceptsContentTypeAttribute>(out var acceptsAtt))
{
Metadata.Accepts(RequestType, false, acceptsAtt.ContentTypes[0], acceptsAtt.ContentTypes[1..]);
}
else
{
Metadata.Accepts(RequestType, false, "application/json");
}
}
Expand Down
14 changes: 9 additions & 5 deletions src/Http/Wolverine.Http/HttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ private static bool isRequestJson(HttpContext context)
return true;
}

// Support custom MIME types with +json suffix per RFC 6839 (e.g. "application/vnd.myapp.v1+json")
if (contentType.Contains("+json"))
{
return true;
}

return false;
}

Expand Down Expand Up @@ -191,11 +197,9 @@ private static bool acceptsJson(HttpContext context)
}

return headers.Accept
.Any(x => x.MediaType is
{
HasValue: true,
Value: "application/json" or "application/problem+json" or "*/*" or "text/json"
});
.Any(x => x.MediaType is { HasValue: true } &&
(x.MediaType.Value is "application/json" or "application/problem+json" or "*/*" or "text/json"
|| x.MediaType.Value!.Contains("+json")));
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Wolverine.Configuration;
Expand Down Expand Up @@ -162,12 +163,13 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic
services.AddSingleton<HttpTransportExecutor>();

services.AddSingleton(typeof(IProblemDetailSource<>), typeof(ProblemDetailSource<>));
services.AddSingleton<MatcherPolicy, ContentTypeEndpointSelectorPolicy>();

services.ConfigureWolverine(opts =>
{
opts.CodeGeneration.Sources.Add(new NullableHttpContextSource());
});

return services;
}

Expand Down
24 changes: 24 additions & 0 deletions src/Http/WolverineWebApi/ContentNegotiationEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Wolverine.Http;

namespace WolverineWebApi;

public record CreateContentItemV1(string Name);

public record CreateContentItemV2(string Name, string Category);

public record ContentItemCreated(string Name, string? Category, string Version);

public static class ContentNegotiationEndpoints
{
[WolverinePost("/content-negotiation/items"), AcceptsContentType("application/vnd.item.v1+json")]
public static ContentItemCreated CreateV1(CreateContentItemV1 command)
{
return new ContentItemCreated(command.Name, null, "v1");
}

[WolverinePost("/content-negotiation/items"), AcceptsContentType("application/vnd.item.v2+json")]
public static ContentItemCreated CreateV2(CreateContentItemV2 command)
{
return new ContentItemCreated(command.Name, command.Category, "v2");
}
}
6 changes: 5 additions & 1 deletion src/Http/WolverineWebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ public static async Task<int> Main(string[] args)

#region sample_register_custom_swashbuckle_filter

builder.Services.AddSwaggerGen(x => { x.OperationFilter<WolverineOperationFilter>(); });
builder.Services.AddSwaggerGen(x =>
{
x.OperationFilter<WolverineOperationFilter>();
x.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
});

#endregion

Expand Down
Loading