From time to time you have to implement an API that has to have the same endpoint but has to do something differently based on the content of the request.
For example take the OAuth2 /token
endpoint. (This is an example, in real world use you would choose something more secure than this legacy oauth flow)
A post request to that endpoint looks like this:
{
"grant_type": "password",
"username": "johndoe523",
"password": "password123"
}
Then it has to do request validation with something that looks like a username and password.
Sometimes the same endpoint has to handle a request that looks like this:
{
"grant_type": "refresh_token",
"refresh_token": "abcd0123efgh4567ijkl8910",
}
In this case it has to do something with a refresh_token
.
Of course you can create an single method that handles all cases. But then you might sacrifice on some sweet validation or clean controllers.
With a custom MatcherPolicy and a simple attribute you can match some controller methods against the content of the body.
First the controller to show what we want to archieve:
[ApiController]
[Route("api/auth")]
public class AuthenticationController : ControllerBase
{
[Route("token")]
[GrantType("password")]
[HttpPost]
public async Task<IActionResult> PostToken(PasswordGrantTokenRequestDto request)
{
// Handle password logic
return Ok("Response from password grant");
}
[Route("token")]
[GrantType("refresh_token")]
[HttpPost]
public async Task<IActionResult> PostToken(RefreshTokenGrantTokenRequestDto request)
{
// Handle refresh_token logic
return Ok("Response from refresh_token grant");
}
[Route("token", Order = 10)]
[HttpPost]
public async Task<IActionResult> PostToken()
{
return Problem(title: "unsupported_grant_type");
}
// Dto's are here, normally they are in separate files
public class PasswordGrantTokenRequestDto
{
[Required]
[JsonPropertyName("username")]
public string Username { get; set; }
[Required]
[JsonPropertyName("password")]
public string Password { get; set; }
}
public class RefreshTokenGrantTokenRequestDto
{
[Required]
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
}
}
As you can see the first 2 methods do have an GrantType
attribute.
The GrantTypeAttribute
is simple:
public class GrantTypeAttribute : Attribute
{
public string GrantType { get; private set; }
public GrantTypeAttribute(string grantType)
{
GrantType = grantType;
}
}
The real magic happens here, in a custom MatcherPolicy
called GrantTypeMatcherPolicy
.
public class GrantTypeMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
public override int Order { get; } = 100;
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
if (endpoints == null)
{
throw new ArgumentNullException(nameof(endpoints));
}
for (var i = 0; i < endpoints.Count; i++)
{
var endpoint = endpoints[i];
var grantTypeAttribute = endpoint.Metadata.GetMetadata<GrantTypeAttribute>();
if(grantTypeAttribute != null)
{
return true;
}
}
return false;
}
public async Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
httpContext.Request.EnableBuffering(30 * 1024, 30 * 1024);
TokenRequestDto tokenRequestDto = null;
try
{
tokenRequestDto = await JsonSerializer.DeserializeAsync<TokenRequestDto>(httpContext.Request.Body);
}
catch {
// ignore
}
httpContext.Request.Body.Position = 0;
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
var grantTypeAttributes = candidate.Endpoint.Metadata.GetOrderedMetadata<GrantTypeAttribute>();
if (grantTypeAttributes.Any())
{
if (tokenRequestDto == null)
{
candidates.SetValidity(i, false);
}
else
{
candidates.SetValidity(i, grantTypeAttributes.Any(attr => attr.GrantType == tokenRequestDto.GrantType));
}
}
}
}
}
Here we need to implement the IEndpointSelectorPolicy
interface which has 2 methods.
First the AppliesToEndpoints
method. This methods returns true when the GrantTypeAttribute
is present in one of the endpoints metadata.
Secondly, the ApplyAsync
method. This methods first enables buffering. We try to read the body here, and it needs to be read again later. Also note that the position of the body is set back to 0 after the body is read.
First we try to extract an instance of TokenRequestDto
to retrieve the grant_type
field.
public class TokenRequestDto
{
[JsonPropertyName("grant_type")]
public string GrantType { get; set; }
}
Then for each endpoint candidate we check if there are any GrantTypeAttribute
present on the endpoint, and if that is the case we set the validity to true
if the endpoint if the grant type matches, of false
when the grant_type
does not match or is not present at all.
The last thing is to register this MatcherPolicy
in the ConfigureServices
method of the Startup
class.
// ...
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, GrantTypeMatcherPolicy>());
// ...
Now the correct method is called depending on a field in the body of the request.