Reputation: 117
I'm currently trying to support API versioning using .NET Core API.
I have read many articles about the matter, couldn't find a really good code examples for it.
Everyone is posting about the controller and how to add API version to each end point but none is actually talking about the headache afterwards. Meaning duplicating the models and the functions (service/handler).
Let's say I have a User controller which has more than 5 end points. One of these end point is GET User. We needed to remove a field(age field) in the response and it's a breaking change. So we added 2 end point one support the default V1 and the other support V2.
[ApiController]
[Route("api/User")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class UserController : ControllerBase {
[HttpGet("user")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> GetUser([FromQuery] string id)
{
return Ok(await _service.GetUser(id));
}
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
return Ok(await _service.GetUser(id));
}
}
assuming the GetUser()
function has a heavy logic (+30 lines of codes)
the only difference between V1 and V2 is in the model itself one will return the age
and one will not.
What is the better approach to handle such situation? Is it better to duplicate `GetUser() as:
GetUser(int id)
GetUserV2(int id)
Or pass a version number to the function and do the change accordingly:
GetUser(int id , int version)
for my personal opinion. I prefer the duplication as it will be less complicated and easy to read, but duplicating all code also seems useless.
As this is my first time trying to support versioning. I would really appreciate some thoughts and ideas from you!
Upvotes: 2
Views: 2406
Reputation: 4368
There is no "one size fits all" solution. What makes sense for your particular application will vary. Here are few ideas that may work for you. There is no preference in order nor is any one particular solution necessarily better than the other. Some options can even be combined together.
Move as much logic as possible out of your controllers. Controllers are just a way to represent your API over HTTP. By delegating as much of the logic as possible into collaborators, you can likely reduce a lot of duplication.
Ideally, an action method should be less than 10 lines of code. Extension methods, custom results, and so on can help reduce duplication.
Define a clear versioning policy; for example N-2
. This can really help clamp down on duplication, but not necessarily eliminate it. Managing duplication across 3 versions is much more manageable if it's unbound.
It should be noted that sharing across versions also comes with some inherent risks (which you might be willing to accept). For example, a change or fix could affect multiple versions and in unexpected or undesirable ways. This is more likely to occur when interleaving multiple versions on a single controller. Some services choose a Copy & Paste approach for new versions to retain the same base implementation, but then allow the implementations to evolve independently. That doesn't mean you can't have shared components, just be careful what you share.
Use nullable attributes and ensure your serialization options do not emit null
attributes. This obviously doesn't work if you allow or use explicit null
values.
For example, the age
attribute can be removed using a single model like this:
public class User
{
// other attributes omitted for brevity
public int? Age { get; set; }
}
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
var user = await _service.GetUser(id);
// if nulls are not emitted, then this effective 'removes' the
// 'age' member using a single model
user.Age = null;
return Ok(user);
}
Use an adapter. This could get tedious if you don't have a fixed versioning policy, but is manageable for a limited number of versions. You could also using templating or source generators to render the code for you.
public class User2Adapter
{
private readonly User inner;
public User2Adapter(User user) => inner = user;
public FirstName => inner.FirstName;
public LastName => inner.LastName;
}
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
return Ok(new User2Adapter(await _service.GetUser(id)));
}
This approach is used for serializing
ProblemDetails
using Newtonsoft.Json (see here)
This can also be achieved with anonymous types:
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
var user = await _service.GetUser(id);
var userV2 = new
{
firstName = user.FirstName,
lastName = user.LastName,
};
return Ok(userV2);
}
Use a custom OutputFormatter
. The default implementation in SystemJsonTextOutputFormatter doesn't honor the specified object type unless the supplied object itself is null
. You can change this behavior.
A complete implementation would be a bit verbose, but you can imagine that you might have something like this (abridged):
public class VersionedJsonOutputFormatter : TextOutputFormatter
{
private readonly Dictionary<ApiVersion, Dictionary<Type, Type>> map = new()
{
[new ApiVersion(1.0)] = new()
{
[typeof(User)] = typeof(User),
},
[new ApiVersion(2.0)] = new()
{
[typeof(User)] = typeof(User2),
},
}
public VersionedJsonOutputFormatter(
JsonSerializerOptions jsonSerializerOptions)
{
// TODO: copy SystemJsonTextOutputFormatter implementation
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
// IMPORTANT: abridged with many assumptions; look at
// SystemJsonTextOutputFormatter implementation
var httpContext = context.HttpContext;
var apiVersion = httpContext.GetRequestedApiVersion();
var objectType = map[apiVersion][context.Object.GetType()];
var ct = httpContext.RequestAborted;
try
{
await JsonSerializer.SerializeAsync(
responseStream,
context.Object,
objectType,
SerializerOptions,
ct);
await responseStream.FlushAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
}
}
This is just one approach. There are plenty of variations on how you can change the mapping.
This one area where OData (or even EF) really shines. The use of an Entity Data Model (EDM) separates the model over the wire vs the code model. You can have a single, unified code model with a different EDM per API version that controls how that is serialized over the wire. I'm not sure you can yank only the specific bits that you want for EDM and serialization, but if you can, it just might get you what you want with minimal effort. This is approach is certainly useful for APIs outside of the context of OData.
The OData examples for API Versioning show this at work. I've never tried using things in a purely non-OData way, but that doesn't mean it can't be made to work.
Upvotes: 5
Reputation: 11
I would prefer the
GetUser(int id , int version)
and add a few comments on why you're using this version varible and use a switch case inside rather than writing duplicate code. For me personally, writing such duplicate code is not a very good practice as I find it redundant.
Upvotes: -1