Konsumere API-er i en app
En applikasjon kan konsumere åpne og lukkede API som er tilgjengelig via Internett.
ASP.NET Core har gode muligheter til å konsumere API.
Dette kan være nyttig dersom man ønsker å eksponere organisasjonens egne API via en app eller har behov for data fra eksterne API i appen.
På denne siden går vi gjennom et eksempel hvor et eksternt, åpent API benyttes til å berike skjemadata. Eksempelappen kan sees i sin helhet i Altinn Studio.
API-et som benyttes er RestCountries v3 og det er
endepunktet https://restcountries.com/v3.1/name/{country}
vi er interessert i.
Dette returnerer et sett med detaljer om landet som er oppgitt.
Du kan studere responsen ved å kalle API-et fra nettleseren din: https://restcountries.com/v3.1/name/Norway.
Vi ønsker å berike skjemaet med detaljer om et land som sluttbruker har fylt inn.
Opprettelse av API modeller
Dersom API-et som skal konsumeres er dokumentert med Swagger eller OpenAPI kan man enkelt genere C# klasser basert på datamodellen. Dette kan gjøres manuelt eller ved hjelp av verktøy som tilbyr slik generering.
I dette eksempelet er responsobjektet stort og inneholder langt mer data enn den vi er interessert i.
Her er et lite utsnitt av responsobjektet for Norge.
[
{
"name": {
"common": "Norway",
"official": "Kingdom of Norway",
"nativeName": {}
},
"idd": {},
"capital": [
"Oslo"
],
"altSpellings": [],
"region": "Europe",
"subregion": "Northern Europe",
"languages": {},
"translations": {},
"latlng": [
62,
10
],
"landlocked": false,
"borders": [],
"area": 323802,
"demonyms": {},
"flag": "🇳🇴",
"maps": {},
"population": 5379475,
"postalCode": {
"format": "###",
"regex": "^(\\d{4})$"
}
}
]
I applikasjonen ønsker vi kun å ta med oss dataen fra de markerte linjene, altså hovedstad og region. Vi lager et minimalistisk responsobjekt som kun inneholder de feltene vi er interessert i.
I mappen App/models opprettes det en ny fil Country.cs
.
using System.Collections.Generic;
namespace Altinn.App.models
{
public class Country
{
public List<string> Capital { get; set; }
public string Region { get; set; }
}
}
Country
-objektet består av feltene Capital
og Region
.
Capital
er en liste med strenger, da et land kan ha flere hovesteder.
I dette eksempelt krever ikke API-et et komplekst request-objekt og dermed kan vi nøye oss med den ene modellen. Skulle det være behov for et request-objekt kan dette opprettes på samme måte.
Oppsett av interface for klienten
Det er anbefalt å definere et interface for klienten som skal kalle API-et. Det gjør at vi kan benytte oss av styrkene til .NET med dependency injection og effektiv håndtering av HTTP-klienter.
I applikasjonsrepoet opprettes mappen App/clients,
og i den nye mappen opprettes filen ICountryClient.cs
.
Interfacet består av én metode GetCountry
som tar inn en streng og returnerer et Country-objekt.
Definér interfacet som vist nedenfor.
using System.Threading.Tasks;
using Altinn.App.models;
namespace Altinn.App.client
{
public interface ICountryClient
{
/// <summary>
/// Retrieves metadata about the provided country.
/// </summary>
/// <param name="country">The name of the country</param>
/// <returns>A country object</returns>
public Task<Country> GetCountry(string country);
}
}
Returobjektet er omkranset av Task<...>
. Dette er lagt inn for å vise til at kallet som skal gjøres
vil være asynkront.
Implementere klient
Det er klienten som inneholder koden som gjør kallet mot API-et og omformer resultatet til Country
-modellen
som forventes i retur av funksjonene som kaller klienten.
Den fulle implementasjonen av Country-klienten er vist nedenfor.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Altinn.App.models;
using Microsoft.Extensions.Logging;
namespace Altinn.App.client
{
public class CountryClient : ICountryClient
{
HttpClient _client;
ILogger<ICountryClient> _logger;
JsonSerializerOptions _serializerOptions;
public CountryClient(HttpClient client, ILogger<ICountryClient> logger)
{
_logger = logger;
_client = client;
_client.BaseAddress = new Uri("https://restcountries.com/v3.1");
_serializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
}
public async Task<Country> GetCountry(string country)
{
string query = $"name/{country}";
HttpResponseMessage res = await _client.GetAsync(query);
if (res.IsSuccessStatusCode)
{
string resString = await res.Content.ReadAsStringAsync();
List<Country> countryResponse = JsonSerializer.Deserialize<List<Country>>(resString, _serializerOptions);
return countryResponse.Any() ? countryResponse.First() : null;
}
else
{
_logger.LogError("Retrieving country {country} failed with status code {statusCode}", country, res.StatusCode);
return null;
}
}
}
}
Øverst i filen finner du referanser til alle namespace som klassen er avhengig av.
using System;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Altinn.App.models;
using Microsoft.Extensions.Logging;
Videre definerer vi klassen og hvilket interface den arver fra.
public class CountryClient : ICountryClient
Videre er tre private objekter _client, __logger og _serializerOptions.
private readonly HttpClient _client;
private readonly ILogger<ICountryClient> _logger;
private readonly JsonSerializerOptions _serializerOptions;
Understrek foran navnet er kun en navnekonvensjon og har ingen effekt.
- _client vil i konstruktøren populeres med en HTTP-klient.
- _logger vil i konstruktøren populeres med en logger slik at man kan logge feilmeldinger og annet i klassen.
- _serializerOptions vil i konstruktøren instansieres og konfigureres for å kunne deserialisere responsen fra API-et.
Videre i klassen defineres konstruktøren.
public CountryClient(HttpClient client, ILogger<ICountryClient> logger)
{
_logger = logger;
_client = client;
_client.BaseAddress = new Uri("https://restcountries.com/v3.1");
_serializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
}
Objekter populeres dersom de kommer som input i konstruktøren og andre objekter instansieres. Skulle du ha behov for å bruke en av de andre servicene som er registeret i applikasjonen, er det bare å sende den inn i konstruktøren og opprette et privat objekt for å kunne ta det i bruk i klassen, slik vi har gjort med _logger eller _client.
Videre i klassen finner du implementasjonen av metoden GetCountry
.
public async Task<Country> GetCountry(string country)
{
string query = $"name/{country}";
HttpResponseMessage res = await _client.GetAsync(query);
if (res.IsSuccessStatusCode)
{
string resString = await res.Content.ReadAsStringAsync();
List<Country> countryResponse = JsonSerializer.Deserialize<List<Country>>(resString, _serializerOptions);
return countryResponse.Any() ? countryResponse.First() : null;
}
else
{
_logger.LogError("Retrieving country {country} failed with status code {statusCode}", country, res.StatusCode);
return null;
}
}
Her sjekker vi om statuskoden på API-kallet er en suksess-kode, før vi deserialiserer og returnerer objektet. Dersom det ikke er en suksess-statuskode logger vi en feil og returnerer null.
Registrere klienten i applikasjonen
Når interface og klient er implementert kan den registreres i App/Program.cs (.NET 6) eller i App/Startup.cs (.NET 5) for bruk i applikasjonen.
I Program.cs
klassen legger vi til kodelinjen nedenfor.
I tillegg må using Altinn.App.client;
og using Altinn.App.AppLogic.DataProcessing;
legges til øverst i filen.
void RegisterCustomAppServices(IServiceCollection services, IConfiguration config, IWebHostEnvironment env)
{
services.AddHttpClient<ICountryClient, CountryClient>();
services.AddTransient<IDataProcessor, DataProcessor>();
// Register your apps custom service implementations here.
}
Benytte klient i applikasjonslogikk
For å berike skjemadata må vi koble klienten vår til logikken i App/logic/DataProcessingHandler.cs i metoden ProcessDataWrite. Merk at for v7 av applikasjonsmalen er dette endret, se dataprossessering.
Først må klienten tilgjengeliggjøres ved å injecte den inn i konstruktøren til klassen. DataProcessingHandler har ingen konstruktør i utgangspunktet så den må opprettes i klassen.
public DataProcessingHandler()
{
}
Videre kan vi opprette et privat objekt for klienten, injecte den i konstruktøren og assigne den til det private objektet. Resultatet blir seende slik ut:
private readonly ICountryClient _countryClient;
public DataProcessingHandler(ICountryClient countryClient)
{
_countryClient = countryClient;
}
I tillegg må using Altinn.App.client;
også legges til i denne filen.
_countryClient er nå tilgjengelig i DataProcessingHandler, og vi er klare til å implementere logikken i ProcessDataWrite.
public async Task<bool> ProcessDataWrite(Instance instance, Guid? dataId, object data)
{
if (data.GetType() == typeof(skjema))
{
skjema skjema = (skjema)data;
if (!string.IsNullOrEmpty(skjema.land))
{
Country country = await _countryClient.GetCountry(skjema.land.Trim());
if (country != null)
{
skjema.hovedstad = string.Join(",", country.Capital);
skjema.region = country.Region;
}
else
{
skjema.hovedstad = skjema.region = string.Empty;
}
return true;
}
else
{
skjema.hovedstad = string.Empty;
skjema.region = string.Empty;
}
}
return await Task.FromResult(false);
}
Prøver du å bygge applikasjonen nå, vil du få en feil. DataProcessingHandler instansieres i App.cs, så alle dependecies må også inn i denne filen og deretter sendes videre i konstruktøren til DataProcessingHandler.
I filen App/logic/App.cs gjøres følgende endringer:
Legg til en referanse til namespaces til klienten øverst i filen.
using Altinn.App.client;
Inject
ICountryClient
nederst i App.cs-konstruktøren.Dette er gjort på linje 14.
1public App( 2 IAppResources appResourcesService, 3 ILogger<App> logger, 4 IData dataService, 5 IProcess processService, 6 IPDF pdfService, 7 IProfile profileService, 8 IRegister registerService, 9 IPrefill prefillService, 10 IInstance instanceService, 11 IOptions<GeneralSettings> settings, 12 IText textService, 13 IHttpContextAccessor httpContextAccessor, 14 ICountryClient countryClient) : base( 15 appResourcesService, 16 logger, 17 dataService, 18 processService, 19 pdfService, 20 prefillService, 21 instanceService, 22 registerService, 23 settings, 24 profileService, 25 textService, 26 httpContextAccessor)
Legg til countryClient i konstruktøren til DataProcessingHandler.
_dataProcessingHandler = new DataProcessingHandler(countryClient);
Caching av responsdata
En ulempe med eksempelet slik det står nå, er at for hver gang skjemaet lagres, vil man gjøre et kall mot endepunktet for å hente ut data.
Det er rimelig å anta at et lands hovedstad og hvilken region det tilhører ikke vil endre seg hyppig. Har vi hentet informasjon om Norge kan vi lagre denne lokalt i applikasjonen i en tidsperiode, så vi slipper å gjøre kallet igjen.
Kodeendringene beskrives ikke steg for steg, men er vist i sin helhet nedenfor. Det kreves kun endringer i CountryClient.cs.
using Altinn.App.models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Altinn.App.client
{
public class CountryClient : ICountryClient
{
private readonly HttpClient _client;
private readonly ILogger<ICountryClient> _logger;
private readonly JsonSerializerOptions _serializerOptions;
private readonly IMemoryCache _memoryCache;
private readonly MemoryCacheEntryOptions _cacheOptions;
public CountryClient(HttpClient client, ILogger<ICountryClient> logger, IMemoryCache memoryCache)
{
_logger = logger;
_client = client;
_client.BaseAddress = new Uri("https://restcountries.com/v3.1/");
_serializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
_memoryCache = memoryCache;
_cacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
};
}
public async Task<Country> GetCountry(string country)
{
string uniqueCacheKey = "Country_" + country;
// Check if country is present in cache, if so return from cache
if (_memoryCache.TryGetValue(uniqueCacheKey, out Country outputCountry))
{
return outputCountry;
}
string query = $"name/{country}";
HttpResponseMessage res = await _client.GetAsync(query);
if (res.IsSuccessStatusCode)
{
string resString = await res.Content.ReadAsStringAsync();
List<Country> countryResponse = JsonSerializer.Deserialize<List<Country>>(resString, _serializerOptions);
if (countryResponse.Any())
{
outputCountry = countryResponse.First();
// Add response country to cache
_memoryCache.Set(uniqueCacheKey, outputCountry, _cacheOptions);
return outputCountry;
}
else
{
return null;
}
}
else
{
_logger.LogError("Retrieving country {country} failed with status code {statusCode}", country, res.StatusCode);
return null;
}
}
}
}