The short answer here is yes you can. In order to achieve this, you will need to use a discriminator value and play with ModelBinders.
One way for not using versions on your API is through Content-Types. You can keep the same endpoint over time and add multiple Content-Types that allow you to extend your API without breaking the existing functionality already in place. Imagine your initial api only with one endpoint using application.json for example and later getting a new requirement to accept new functionality but keeping old integrations in place. In this case, you will use one Content-Type: application.json to access your method without parameters from the body and another Content-Type: application+body.json to access the other one. For the extra one, you will need to register it on the Startup so the request can read it from the headers. Then on your ModelBinder, you will decide to which model it binds. On top of that, you can implement an ActionConstraint to only hit the endpoint you need based on the Content-Type.
Below is an example with the WeatherForecast that is coming by default when you create a .Net Core API.
As you can see in the example below I am overloading the endpoint with 2 different Dtos, the model binder takes into consideration the Content-Types to bind the Dto to the correct model.
ActionContraint restrict the request to hit the endpoint
C# allows you to overload a method, but with the.NetCore routes you don't have that kind of flexibility using the same ActionMethods if they keep the same names you will get an exception. That's why you need to create 2 different methods for each of the Dtos that you are planning to pass. In your case, you have one method that is empty and the other one that receives a parameter, so that is an easier scenario.
On Startup.cs
public void ConfigureServices(IServiceCollection services)
{
var iMvcBuilder = services.AddControllers(mvcOptions =>
{
//mvcOptions.EnableEndpointRouting = false;
mvcOptions.ModelBinderProviders.Insert(0, new WeatherForecastForCreationModelBinderProvider());
var jsonOutputFormatter = mvcOptions.OutputFormatters.OfType<SystemTextJsonOutputFormatter>()
.FirstOrDefault();
//TODO: test to remove this, no need to have this if you put the correct Produces or Consumes attributes
if (jsonOutputFormatter != null)
{
jsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.weatherforecast.type1.json");
jsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.weatherforecast.type2.json);
}
})
Model Binder WeatherForecastForCreationModelBinderProvider
public class WeatherForecastForCreationModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType == typeof(WeatherForecastForCreationDto1) || context.Metadata.ModelType == typeof(WeatherForecastForCreationDto2))
{
return new WeatherForecastForCreationModelBinder();
}
return null;
}
}
Model Binder WeatherForecastForCreationModelBinder
public class WeatherForecastForCreationModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
throw new ArgumentNullException(nameof(bindingContext));
string valueFromBody = string.Empty;
using (var sr = new StreamReader(bindingContext.HttpContext.Request.Body))
{
valueFromBody = sr.ReadToEndAsync().GetAwaiter().GetResult();
}
if (string.IsNullOrEmpty(valueFromBody))
{
return Task.CompletedTask;
}
string contentType = bindingContext.HttpContext.Request.Headers.FirstOrDefault(x => x.Key == HeaderNames.ContentType).Value;
dynamic model = null;
var isDeserializingOk = true;
switch (contentType)
{
case "application/vnd.weatherforecast.type1.json":
try
{
model = JsonConvert.DeserializeObject<WeatherForecastForCreationDto1>(valueFromBody);
}
catch
{
isDeserializingOk = false;
}
break;
case "application/vnd.weatherforecast.type2.json":
try
{
model = JsonConvert.DeserializeObject<WeatherForecastForCreationDto2>(valueFromBody);
}
catch
{
isDeserializingOk = false;
}
break;
}
if (!isDeserializingOk)
{
bindingContext.Result = ModelBindingResult.Failed();
return Task.CompletedTask;
}
bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
Controller methods:
/// <summary>
/// Create Weather Forecast
/// </summary>
/// <param name="weatherForecast"></param>
/// <returns></returns>
[HttpPost(Name = "CreateWeatherForecast")]
[ProducesResponseType(201)]
[Consumes(HttpMediaTypes.WeatherForecastType1)]
[RequestHeaderMatchesMediaType("Content-Type", new[] { HttpMediaTypes.WeatherForecastType1 })]
public async Task<IActionResult> CreateWeatherForecast1(
[FromBody] WeatherForecastForCreationDto1 weatherForecastForCreationDto1,
[FromServices] IWeatherForcastService weatherForcastService,
[FromServices] IMapper mapper)
{
if (!ModelState.IsValid)
{
return ValidationFailureRequest();
}
var entity = await weatherForcastService.CreateSummary1Async(weatherForecastForCreationDto1);
var outputDto = mapper.Map<WeatherForecastDto1>(entity);
return StatusCode(StatusCodes.Status201Created, outputDto);
}
[HttpPost]
[ProducesResponseType(201)]
[Consumes(HttpMediaTypes.WeatherForecastType2)]
[RequestHeaderMatchesMediaType("Content-Type", new[] { HttpMediaTypes.WeatherForecastType2 })]
public async Task<IActionResult> CreateWeatherForecast2(
[FromBody] WeatherForecastForCreationDto2 weatherForecastForCreationDto2,
[FromServices] IWeatherForcastService weatherForcastService,
[FromServices] IMapper mapper)
{
if (!ModelState.IsValid)
{
return ValidationFailureRequest();
}
var entity = await weatherForcastService.CreateSummary2Async(weatherForecastForCreationDto2);
var outputDto = mapper.Map<WeatherForecastDto2>(entity);
return StatusCode(StatusCodes.Status201Created, outputDto);
}
DTOs: WeatherForecastForCreationDto1 and WeatherForecastForCreationDto2
public class WeatherForecastForCreationDto1
{
/// <summary>
/// Date
/// </summary>
/// <example>
/// <code>
/// DateTime.Now;
/// </code>
/// </example>
public DateTime Date { get; set; }
/// <summary>
/// Temperature C
/// </summary>
/// <example>
/// 14
/// </example>
public int TemperatureC { get; set; }
/// <summary>
/// Summary
/// </summary>
/// <example>
/// Scorching
/// </example>
public string Summary { get; set; }
}
public class WeatherForecastForCreationDto2
{
/// <summary>
/// Date
/// </summary>
/// <example>
/// <code>
/// DateTime.Now;
/// </code>
/// </example>
public DateTime Date { get; set; }
/// <summary>
/// Temperature C
/// </summary>
/// <example>
/// 35
/// </example>
public int TemperatureC { get; set; }
}
And here is your ActionContraint RequestHeaderMatchesMediaTypeAttribute
[AttributeUsage(AttributeTargets.All, Inherited = true, AllowMultiple = true)]
public class RequestHeaderMatchesMediaTypeAttribute : Attribute, IActionConstraint
{
private readonly string[] _mediaTypes;
private readonly string _requestHeaderToMatch;
public RequestHeaderMatchesMediaTypeAttribute(string requestHeaderToMatch,
string[] mediaTypes)
{
_requestHeaderToMatch = requestHeaderToMatch;
_mediaTypes = mediaTypes;
}
public int Order => 0;
public string RequestHeaderToMatch => _requestHeaderToMatch;
public string[] MediaTypes => _mediaTypes;
public bool Accept(ActionConstraintContext context)
{
var requestHeaders = context.RouteContext.HttpContext.Request.Headers;
if (!requestHeaders.ContainsKey(_requestHeaderToMatch))
{
return false;
}
// if one of the media types matches, return true
foreach (var mediaType in _mediaTypes)
{
var mediaTypeMatches = string.Equals(requestHeaders[_requestHeaderToMatch].ToString(),
mediaType, StringComparison.OrdinalIgnoreCase);
if (mediaTypeMatches)
{
return true;
}
}
return false;
}
}