Metodologie per il versionamento delle API - UGIdotNET
Questo sito si serve dei cookie per fornire servizi. Utilizzando questo sito acconsenti all'utilizzo dei cookie. Ulteriori informazioni Ok

Metodologie per il versionamento delle API

di pubblicato il 11/09/2023

ASP.NET Core  Back-end 

In questo articolo illustriamo alcune metodologie per il versioning delle web API.

Può capitare di avere delle api che vengano utilizzate da applicazioni che non vengono mantenute dal nostro team. In tal caso, se facciamo una modifica aggiungendo o togliendo dei parametri in input o output a una api o eliminiamo un metodo esposto, queste applicazioni esterne devono adeguarsi alle nostre modifiche. Essendo queste applicazioni non di nostra competenza, non e' detto che l'adeguamento venga fatto immediatamente. Possiamo quindi utilizzare un sistema di Versioning, che permette alla nostra api di evolvere, ma di avere una retrocompatibilità con le applicazioni che non vengono immediatamente adeguate.

Il codice

Utilizzeremo il package Asp.Versioning.Http che possiamo installare nella nostra applicazione con nuget.

Le nostre versioni avranno questo formato

    [Version Group.]Major.Minor[-Status]

Dove Version Group ha formato YYYY-MM-DD e non sarà obbligatorio, mentre Status indichera se la versione e' in alpha, beta o qualsiasi altro tag che vogliamo inserire. Anche Status non e' obbligatorio. Major e Minor sono gli unici elementi obbligatori.
Ci sono vari modi di utilizzare il versioning, noi ne analizzeremo due. Iniziamo con il codice in program.cs, all'avvio della applicazione, che man mano andremo a commentare. E' possibile scaricare il codice di esempio.

builder.Services.AddApiVersioning(options =>
{
  
//Se il chiamante non specifica una versione, usiamo quella di default
  options.AssumeDefaultVersionWhenUnspecified = true;
  
//Questa e' la versione di Default.
  options.DefaultApiVersion = new ApiVersion(1, 0);
  
//Definiamo le strategie per stabilire quale versione il chiamante vuole utilizzare.
  //Ne possiamo definire più di una, in tal modo il chiamante potrà usare quella che desidera

  options.ApiVersionReader = ApiVersionReader.Combine(
    
//sono disponibili anche UrlPath e MediaType che per semplicità non utilizziamo
    new HeaderApiVersionReader("api-version"),
    new QueryStringApiVersionReader(
"api-version"));
  
//Permettiamo alla api di aggiungere due header che servono per stabilire quali versioni sono supportate e quali deprecate
  
//ma ancora in servizio.
  options.ReportApiVersions = true;
  
//Diamo informazioni su una versione rilasciata
  options.Policies
    .Sunset(1.0)
    .Effective(new DateTimeOffset(2023, 9, 1, 0, 0, 0, TimeSpan.Zero))
    .Link(
"https://localhost:7124/WhatsNewVersion21alfa")
    .Language(
"it")
    .Title(
"Titolo")
    .Type(
"text/html");
})

Analizziamo il primo controller nell'esempio scaricabile ApiVersionController
 

namespace ApiVersioning.Controllers
{
  [ApiController]
  [Route(
"api/[controller]")]
  
//Questa versione e' deprecata ma ancora esistente.
  [ApiVersion("1.0", Deprecated = true)]
  [ApiVersion(
"2.0")]
  public class ApiVersionController : ControllerBase
  {
    
//Questo metodo non ha versioni differenti. Ne esiste una sola
    [HttpGet()]
    [Route(
"MetodoSenzaVersione")]
    public IActionResult Get()
    {
      return Ok(
"Metodo senza versioni differenti");
    }
    
//Può essere richiamato con il reader QueryString, MetodoGetConVersione?api-version=1.0
    //Può essere richiamato con il reader Header, inserendo nella chiamata, negli header api-version=1.0
    [HttpGet(), MapToApiVersion("1.0")]
    [Route(
"MetodoGetConVersione")]
    public IActionResult GetV1()
    {
      return Ok(
"Metodo in Get V1");
    }
    
//Questo metodo ha la versione 2.0. Per cui, se lo chiamiamo senza versione, dei due metodi con lo stesso nome verrà eseguito questo
    [HttpGet(), MapToApiVersion("2.0")]
    [Route(
"MetodoGetConVersione")]
    public IActionResult GetV2()
    {
      return Ok(
"Metodo in Get V2");
    }
  }
}

Vediamo che ci sono due attributi sul controller che definiscono le possibili versioni richiamabili: la "1.0" e la "2.0". Una di queste versioni e' marcata come deprecata. Per questo comparirà negli headers della response un record che indicherà che la versione "1.0" è deprecata.
Abbiamo un metodo che non possiede versionamento, che chiamiamo MetodoSenzaVersione. Questo significa che se chiamiamo questo metodo con la versione 1.0 o con la 2.0 non cambia nulla. Eseguiremo sempre lo stesso codice.

Il metodo chiamato MetodoGetConVersione ha due versioni. La versione "1.0" può essere chiamata in tre modi:
  • Senza indicare la versione, perche' se la versione dichiarata in program.cs e' "1.0", questa sarà quella utilizzata (vedi proprietà AssumeDefaultVersionWhenUnspecified e DefaultApiVersion in program.cs).
  • Indicando la versione in Querystring MetodoGetConVersione?api-version=1.0: questo perche' in program.cs abbiamo indicato come metodo di reader delle versioni QueryStringApiVersionReader
  • Indicando la versione in header api-version=1.0: questo perche' in program.cs abbiamo indicato come metodo di reader delle versioni HeaderApiVersionReader
Invece la versione v2, sempre del metodo MetodoGetConVersione, potrà essere chiamata solo negli ultimi due modi sopra esposti, sostituendo "1.0" con "2.0". Un'altro reader che possiamo usare MediaTypeApiVersionReader

 

Vediamo ora come risponde la chiamata al servizio MetodoGetConVersione tramite Postman, usando la versione indicata nell'header.
Chiamata Postman con versione e risposta

Negli headers della risposta sono stati messi due valori, uno per indicare le versioni supportate e uno per quelle deprecate: questo grazie alla proprietà ReportApiVersions indicata in program.cs che permette di aggiungere automaticamente questi header. La versione "1.0" è deprecata, l'abbiamo indicato con un attributo.

Chiamata Postman con versione nell'header

Grazie alla proprietà DefaultApiVersion e AssumeDefaultVersionWhenUnspecified in program.cs il chiamante non deve immediatamente adeguarsi. La versione di default, se non indicata esplicitamente, sarà sempre la "1.0". Quindi avremo una versione "1.0" e una versione "2.0" e il chiamante si adeguerà alla versione "2.0".

Quando tutti i servizi esterni saranno allineati all'ultima versione, e su questo controller non avremo più necessità di versionamento, occorrerà fare una modifica se vogliamo eliminare i metodi non usati ed gli attributi di versionamento.
Marcheremo il controller o il metodo come "Neutrale" con l'attributo [ApiVersionNeutral] ed elimineremo tutte le vecchie versioni lasciando solo l'ultima, così da poter fare il rilascio della nostra webapi. Grazie a questo attributo, quando un servizio richiamerà il nostro metodo indicando una versione (perche' abbiamo fatto ora il rilascio e i servizi chiamanti non si sono ancora adeguati a questa variazione), questa sarà ignorata.
Infine potremo chiedere per pulizia di codice di rimuovere i parametri di versionamento, cosa che potrà essere fatta senza urgenza grazie al parametro suddetto.
 

namespace ApiVersioning.Controllers
{
  [ApiController]
  [Route(
"api/[controller]")]
  [ApiVersionNeutral]
  public class ApiVersionController : ControllerBase
  {
    [HttpGet()]
    [Route(
"MetodoGetConVersione")]
    public IActionResult Get()
    {
      return Ok(
"Metodo in Get V2");
    }
  }
}

Questo approccio ha un vantaggio e uno svantaggio.

  • Vantaggio: Possiamo avere all'interno del nostro controller anche solo un metodo con più di una versione. I restanti metodi rimangono come sono, senza dover indicare esplicitamente la versione.
  • Svantaggio: Stiamo obbligando il chiamante ad inserire in header o in QueryString un parametro solo per alcuni metodi nel momento in cui si deve adeguare alla nuova versione.

 

Probabilmente un'altro approccio, più comodo per il chiamante, e' quello di versionare l'intero Controller mantenendo le due versioni su due namespace diversi: è il caso di ApiVersion2Controller che andremo ad analizzare.

namespace ApiVersioning.V1.Controllers
{
  [ApiController]
  [Route(
"api/[controller]")]
  [ApiVersion(
"1.0")]
  [Route(
"api/VersionAllMethodsController")]
  public class ApiVersion2Controller : ControllerBase
  {
    private readonly IApiVersion2Business _apiVersion2Business;
    public ApiVersion2Controller(IApiVersion2Business apiVersion2Business)
    {
      _apiVersion2Business = apiVersion2Business;
    }

    [HttpPost()]
    [Route(
"Post")]
    public IActionResult Post(PostRequest postRequest)
    {
      return Ok($
"Name: {postRequest.Name}");
    }

    [HttpPost()]
    [Route(
"PostUgualeFraVersioni")]
    public IActionResult PostUgualeFraVersioni(PostUgualeFraVersioniRequest postRequest)
    {
      return Ok(_apiVersion2Business.PostUgualeFraVersioni(postRequest));
    }
  }
}

namespace ApiVersioning.V2.Controllers
{
  [ApiController]
  [Route(
"api/[controller]")]
  [ApiVersion(
"2.1-alfa")]
  [Route(
"api/v{version:apiVersion}/VersionAllMethodsController")]
  public class ApiVersion2Controller : ControllerBase
  {
    private readonly IApiVersion2Business _apiVersion2Business;
    public ApiVersion2Controller(IApiVersion2Business apiVersion2Business)
    {
      _apiVersion2Business = apiVersion2Business;
    }

    [HttpPost()]
    [Route(
"Post")]
    public IActionResult Post(Models.PostRequest postRequest)
    {
      return Ok($
"Name: {postRequest.Name}\nCognome {postRequest.Surname}");
    }

    [HttpPost()]
    [Route(
"PostUgualeFraVersioni")]
    public IActionResult PostUgualeFraVersioni(PostUgualeFraVersioniRequest postRequest)
    {
      return Ok(_apiVersion2Business.PostUgualeFraVersioni(postRequest));
    }
  }
}

Abbiamo due namespace, uno per la versione V1.0 e uno per la versione V2.1.alfa. Entrambe contengono un controller con lo stesso nome. Sul primo, ossia la versione che e' obsoleta in quanto stiamo introducendo la nuova versione v2, metteremo come routing [Route("api/VersionAllMethodsController")] senza indicare la versione. In tal modo se il servizio chiamante esterno non si adegua immediatamente alla nuova versione, tutto funziona come prima.

La seconda versione del controller la chiameremo con Postman nel seguente modo:

Chiamata alla seconda versione del controller con Postman

Possiamo notare che in questa chiamata, abbiamo inserito anche una Minor version e uno status, in questo caso alfa. I due model di request sono diversi, pur avendo lo stesso nome, perche' contenuti in namespace diversi.

Anche in questo caso, quando tutti i servizi si saranno adeguati, potremo eliminare il controller vecchio. Il primo passaggio sarà quello di inserire gli attributi al controller in questo modo

[ApiController]
[Route(
"api/[controller]")]
[ApiVersionNeutral]
[Route(
"api/v{version:apiVersion}/VersionAllMethodsController")]
[Route(
"api/VersionAllMethodsController")]

Poi effettueremo il rilascio della nostra api e chiederemo ai servizi chiamanti esterni di eliminare la versione e di usare solo la seconda Route. Quando i servizi saranno tutti allineati, potremo togliere l'attributo neutral e lasciare in questo modo gli attributi:

[ApiController]
[Route(
"api/[controller]")]
[Route(
"api/VersionAllMethodsController")]

Oppure possiamo togliere i vecchi controller e lasciare che i servizi continuino a usare l'url con la versione più recente e che di volta in volta verrà incrementata.

 

Anche in questo caso, questa tecnica presenta vantaggi e svantaggi.

  • Vantaggio: Il chiamante non deve, per ogni metodo utilizzato e versionato, inserire un header o un parametro in QueryString ma deve solo modificare la url base, inserendo il numero della versione desiderata
  • Svantaggi:
    • Per ogni nuova versione dobbiamo "duplicare" un controller, quindi avremo un nuovo namespace da gestire e rename da effettuare nel codice
    • Se un metodo nella versione nuova del controller(V2) non ha una variazione di comportamento, quindi e' un metodo il cui comportamento e' uguale alla versione base(v1), dal momento che il chiamante utilizzerà un urlbase diverso (v2), dobbiamo duplicare del codice per poter eseguire il metodo della versione v1.
Il secondo svantaggio, lo possiamo vedere nel codice con il metodo "PostUgualeFraVersioni". Questa duplicazione di codice, può essere mitigata se nel nostro controller mettiamo solo il codice essenziale per chiamare una classe di business logic come nell'esempio, ossia se consideriamo il nostro controller come un facade per esporre alcuni metodi all'esterno.

 

Se abbiamo un controller senza versione, possiamo omettere ApiVersionNeutral. Se però il chiamante dichiarerà la versione, il nostro servizio andrà in errore.
Nel seguente controller ApiWithoutVersionController non c'è traccia di attributi di versionamento

  [ApiController]
  [Route(
"api/[controller]")]
  public class ApiWithoutVersionController : ControllerBase
  {
    [HttpGet()]
    [Route(
"Get")]
    public IActionResult Get()
    {
      return Ok(
"Metodo senza versioni");
    }
  }

Se però il chiamante inserisce in header la versione avremo un errore: Bad Request.

Bad Request su chiamata version neutral

Se vogliamo informare gli sviluppatori delle novità contenute nelle nostre api, possiamo usare le policy definite in program.cs

Informazioni agli sviluppatori sulle novità delle API

dove stiamo indicando la data di rilascio, le novità attraverso un link a una pagina web, un titolo, linguaggio della pagina.

Ci sono altre opzioni che possiamo usare in questo package per il versionamento. La documentazione e' qui.

Integrazione con swagger

Per prima cosa, occorre installare un nuovo package da nuget nel nostro progetto Asp.Versioning.Mvc.ApiExplorer.

builder.Services.AddApiVersioning(options =>
{
  
//Codice per inserire la versione nella nostra api
})
.AddApiExplorer();

Aggiungiamo due righe di codice nel servizio, una per registrare una dipendenza e una per generare la pagina di swagger.

builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
builder.Services.AddSwaggerGen(options => options.OperationFilter<SwaggerSetParametersValues>());

 

Il contenuto di ConfigureSwaggerOptions serve a personalizzare la pagina di swagger con titolo e descrizione.

public void Configure(SwaggerGenOptions options)
{
  options.AddSecurityDefinition(
"Bearer", new OpenApiSecurityScheme
  {
    Type = SecuritySchemeType.ApiKey,
    Scheme = JwtBearerDefaults.AuthenticationScheme,
    BearerFormat =
"JWT",
    Name =
"Authorization",
    Description =
"Type into the textbox your JWT token.",
    In = ParameterLocation.Header
  });

  foreach (var description in provider.ApiVersionDescriptions)
  {
    options.SwaggerDoc(
      description.GroupName,
      new OpenApiInfo()
      {
        Title = $
"My API version {description.ApiVersion}",
        Description = $
"My API version {description.ApiVersion}",
        Version = description.ApiVersion.ToString(),
      });
  }
}

Con AddSecurityDefinition abbiamo aggiunto la possibilità di avere la configurazione della autenticazione con JwtToken. Per questo installiamo da nuget Microsoft.AspNetCore.Authentication.JwtBearer
Cliccando su Authorize, possiamo inserire il token

Autorizzazione delle web API

Il contenuto di SwaggerSetParametersValues serve a inserire nei parametri di input di swagger i valori di default presi da ApiExplorer e per impostare come non obbligatori i parametri che definiscono la versione.
Dal momento che i parametri che definiscono la versione sono due, ossia Querystring e Header, uno solo dei due è obbligatorio.

Selezione dei parametri di default

Infine generiamo tanti documenti json diversi in base al numero di versioni che abbiamo.
Ogni json è raggruppato per versione. In tal modo, potremo selezionare in swagger una versione, e vedere quali Api implementano quella versione.

 

app.UseSwaggerUI(
options =>
{
  foreach (var description in app.DescribeApiVersions())
  {
    
//Creiamo più json, uno per ogni versione.
    options.SwaggerEndpoint(
        $
"/swagger/{description.GroupName}/swagger.json",
        description.GroupName);
  }
});

Nella seguente immagine, abbiamo selezionato la Versione 2.0 nel menù a tendina in alto e ci vengono mostrati solo i metodi esposti per quella versione.

Selezione della API version 2 nello swagger

Conclusioni

A questo link riportiamo il progetto di versioning completo, con la documentazione.

Se dobbiamo mantenere in essere una grande quantità di metodi e controller con versioni differenti, potremmo arrivare a un punto in cui e' difficile far coesistere il tutto.
Lo scopo del versioning dovrebbe essere quello di facilitare una transizione da una versione all'altra quando questo e' possibile.