diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index 94b3ad9364..1e6347348e 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -15,7 +15,6 @@ - diff --git a/src/Altinn.App.Core/Configuration/DanSettings.cs b/src/Altinn.App.Core/Configuration/DanSettings.cs new file mode 100644 index 0000000000..21d1224b9f --- /dev/null +++ b/src/Altinn.App.Core/Configuration/DanSettings.cs @@ -0,0 +1,17 @@ +namespace Altinn.App.Core.Configuration; + +/// +/// Settings for DanClient +/// +public class DanSettings +{ + /// + /// Base url for Dan API + /// + public required string BaseUrl { get; set; } + + /// + /// Api subscription keys + /// + public required string SubscriptionKey { get; set; } +} diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 0e4dc047f4..3c5b0b0c97 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ using Altinn.App.Core.Infrastructure.Clients.AccessManagement; using Altinn.App.Core.Infrastructure.Clients.Authentication; using Altinn.App.Core.Infrastructure.Clients.Authorization; +using Altinn.App.Core.Infrastructure.Clients.Dan; using Altinn.App.Core.Infrastructure.Clients.Events; using Altinn.App.Core.Infrastructure.Clients.KeyVault; using Altinn.App.Core.Infrastructure.Clients.Pdf; @@ -36,6 +37,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; +using Altinn.App.Core.Internal.Dan; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Events; using Altinn.App.Core.Internal.Expressions; @@ -195,6 +197,12 @@ IWebHostEnvironment env services.Configure(configuration.GetSection("AccessTokenSettings")); services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); + var danSettings = configuration.GetSection("DanClientSettings"); + if (danSettings.Exists()) + { + services.AddHttpClient(); + services.Configure(danSettings); + } services.AddRuntimeEnvironment(); if (env.IsDevelopment()) diff --git a/src/Altinn.App.Core/Implementation/PrefillSI.cs b/src/Altinn.App.Core/Implementation/PrefillSI.cs index 7e5a1722d9..d3f0e513bd 100644 --- a/src/Altinn.App.Core/Implementation/PrefillSI.cs +++ b/src/Altinn.App.Core/Implementation/PrefillSI.cs @@ -3,6 +3,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Auth; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Dan; using Altinn.App.Core.Internal.Prefill; using Altinn.App.Core.Internal.Registers; using Altinn.Platform.Profile.Models; @@ -20,10 +21,12 @@ public class PrefillSI : IPrefill private readonly IAppResources _appResourcesService; private readonly IRegisterClient _registerClient; private readonly IAuthenticationContext _authenticationContext; + private readonly IDanClient? _danClient; private readonly Telemetry? _telemetry; private static readonly string _erKey = "ER"; private static readonly string _dsfKey = "DSF"; private static readonly string _userProfileKey = "UserProfile"; + private static readonly string _danKey = "DAN"; private static readonly string _allowOverwriteKey = "allowOverwrite"; private bool _allowOverwrite = false; @@ -34,12 +37,14 @@ public class PrefillSI : IPrefill /// The app's resource service /// The authentication context /// The service provider + /// The Dan client /// Telemetry for traces and metrics. public PrefillSI( ILogger logger, IAppResources appResourcesService, IAuthenticationContext authenticationContext, IServiceProvider serviceProvider, + IDanClient? danClient = null, Telemetry? telemetry = null ) { @@ -47,6 +52,7 @@ public PrefillSI( _appResourcesService = appResourcesService; _registerClient = serviceProvider.GetRequiredService(); _authenticationContext = authenticationContext; + _danClient = danClient; _telemetry = telemetry; } @@ -206,6 +212,63 @@ when await systemUser.LoadDetails() is { } details && details.Party.PartyId == p } } } + + // Prefill from Dan + JToken? danConfiguration = prefillConfiguration.SelectToken(_danKey); + if (danConfiguration != null && _danClient != null) + { + var datasetList = danConfiguration.SelectToken("datasets"); + if (datasetList != null) + { + foreach (var dataset in datasetList) + { + var datasetName = dataset.SelectToken("name")?.ToString(); + var subject = !string.IsNullOrWhiteSpace(party.SSN) ? party.SSN : party.OrgNumber; + + if (string.IsNullOrEmpty(subject)) + { + _logger.LogError( + "Could not prefill from {DanKey}, no valid subject (SSN or OrgNumber) found for party", + _danKey + ); + continue; + } + + var fields = dataset.SelectToken("mappings"); + if (fields != null) + { + var danPrefill = fields + .SelectMany(obj => obj.Children()) + .ToDictionary(prop => prop.Name, prop => prop.Value.ToString()); + + if (datasetName != null) + { + var danDataset = await _danClient.GetDataset(datasetName, subject, fields.ToString()); + if (danDataset.Count > 0) + { + JObject danJsonObject = JObject.FromObject(danDataset); + _logger.LogInformation($"Started prefill from {_danKey}"); + LoopThroughDictionaryAndAssignValuesToDataModel( + SwapKeyValuesForPrefill(danPrefill), + danJsonObject, + dataModel + ); + } + else + { + string errorMessage = $"Could not prefill from {_danKey}, data is not defined."; + _logger.LogError(errorMessage); + } + } + else + { + string errorMessage = $"Could not prefill from {_danKey}, dataset name is not defined."; + _logger.LogError(errorMessage); + } + } + } + } + } } /// diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Dan/DanClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Dan/DanClient.cs new file mode 100644 index 0000000000..46c8e8c8ef --- /dev/null +++ b/src/Altinn.App.Core/Infrastructure/Clients/Dan/DanClient.cs @@ -0,0 +1,117 @@ +using System.Text; +using System.Text.Json; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Internal.Dan; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using JsonException = Newtonsoft.Json.JsonException; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Altinn.App.Core.Infrastructure.Clients.Dan; + +/// +/// Client for interacting with the Dan API. +/// +public class DanClient : IDanClient +{ + private readonly HttpClient _httpClient; + private readonly IOptions _settings; + + /// + /// Constructor + /// + /// + /// + public DanClient(IHttpClientFactory factory, IOptions settings) + { + _httpClient = factory.CreateClient("DanClient"); + _settings = settings; + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", _settings.Value.SubscriptionKey); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + _httpClient.BaseAddress = new Uri(settings.Value.BaseUrl); + } + + /// + /// Returns dataset from Dan API. + /// + /// Dataset from Dan + /// Usually ssn or orgNumber + /// The fields we fetch from the api + /// + public async Task> GetDataset(string dataset, string subject, string fields) + { + var body = new { Subject = subject }; + var myContent = JsonConvert.SerializeObject(body); + + var fieldsToFill = GetQuery(fields); + if (fieldsToFill.Count == 0) + return new Dictionary(); + + var baseQuery = $"{fieldsToFill.First()} : {fieldsToFill.First()}"; + foreach (var jsonKey in fieldsToFill.Skip(1)) + { + //if there is more than one field to fetch, add it to the query + baseQuery += $",{jsonKey} : {jsonKey}"; + } + + //ensures that the query returns a list if endpoint returns a list and an object when endpoint returns a single object + var query = "[].{" + baseQuery + "}||{" + baseQuery + "}"; + using (var content = new StringContent(myContent, Encoding.UTF8, "application/json")) + { + var result = await _httpClient.PostAsync( + $"directharvest/{dataset}?envelope=false&reuseToken=true&query={query}", + content + ); + + if (result.IsSuccessStatusCode) + { + Dictionary? dictionary; + + var resultJson = await result.Content.ReadAsStringAsync(); + + dictionary = IsJsonArray(resultJson) + ? await ConvertListToDictionary(resultJson) + : JsonConvert.DeserializeObject>(resultJson); + + return dictionary ?? new Dictionary(); + } + } + return new Dictionary(); + } + + private static Task> ConvertListToDictionary(string jsonString) + { + var list = JsonConvert.DeserializeObject>>(jsonString); + if (list != null) + return Task.FromResult( + list.SelectMany(d => d) + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => string.Join(",", g.Select(x => x.Value))) + ); + + return Task.FromResult(new Dictionary()); + } + + private static bool IsJsonArray(string jsonString) + { + try + { + using var doc = JsonDocument.Parse(jsonString); + return doc.RootElement.ValueKind == JsonValueKind.Array; + } + catch (JsonException) + { + return false; + } + } + + private static List GetQuery(string json) + { + var list = JsonSerializer.Deserialize>>(json); + if (list != null) + return list.Where(l => l.Count != 0).Select(l => l.Keys.First()).ToList(); + return new List(); + } +} diff --git a/src/Altinn.App.Core/Internal/Dan/IDanClient.cs b/src/Altinn.App.Core/Internal/Dan/IDanClient.cs new file mode 100644 index 0000000000..fe0e6255e1 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Dan/IDanClient.cs @@ -0,0 +1,16 @@ +namespace Altinn.App.Core.Internal.Dan; + +/// +/// DanClient interface +/// +public interface IDanClient +{ + /// + /// Method for getting a selected dataset from Dan Api + /// + /// Name of the dataset + /// Usually ssn or OrgNumber + /// fields to fetch from endpoint + /// + public Task> GetDataset(string dataset, string subject, string fields); +} diff --git a/test/Altinn.App.Api.Tests/Program.cs b/test/Altinn.App.Api.Tests/Program.cs index a2db05bc5d..b8f504c580 100644 --- a/test/Altinn.App.Api.Tests/Program.cs +++ b/test/Altinn.App.Api.Tests/Program.cs @@ -8,9 +8,11 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Cache; +using Altinn.App.Core.Infrastructure.Clients.Dan; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; +using Altinn.App.Core.Internal.Dan; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Events; using Altinn.App.Core.Internal.Instances; @@ -67,6 +69,11 @@ builder.Services.Configure(settings => settings.DisableLocaltestValidation = true); builder.Services.Configure(settings => settings.DisableAppConfigurationCache = true); builder.Services.Configure(settings => settings.IsTest = true); +builder.Services.Configure(settings => +{ + settings.BaseUrl = "http://localhost:7071/v1/"; + settings.SubscriptionKey = "test-subscription-key"; +}); builder.Configuration.GetSection("GeneralSettings:IsTest").Value = "true"; // AppConfigurationCache.Disable = true; diff --git a/test/Altinn.App.Core.Tests/Implementation/PrefillSITest.cs b/test/Altinn.App.Core.Tests/Implementation/PrefillSITest.cs index 37266defc2..204188eb69 100644 --- a/test/Altinn.App.Core.Tests/Implementation/PrefillSITest.cs +++ b/test/Altinn.App.Core.Tests/Implementation/PrefillSITest.cs @@ -1,7 +1,9 @@ using Altinn.App.Core.Features.Auth; using Altinn.App.Core.Implementation; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Dan; using Altinn.App.Core.Internal.Registers; +using Altinn.Platform.Register.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; @@ -13,6 +15,12 @@ public class PrefillTestDataModel public TestPrefillFields? Prefill { get; set; } } +public class PrefillDanTestDataModel +{ + public string? Email { get; set; } + public string? OrganizationNumber { get; set; } +} + public class TestPrefillFields { public string? EraSourceEnvironment { get; set; } @@ -47,6 +55,7 @@ public async Task PrefillDataModel_AssignsValuesCorrectly() var authenticationContextMock = new Mock(); var services = new ServiceCollection(); var registryClientMock = new Mock(); + var danClientMock = new Mock(); services.AddSingleton(registryClientMock.Object); await using var sp = services.BuildStrictServiceProvider(); @@ -54,7 +63,8 @@ public async Task PrefillDataModel_AssignsValuesCorrectly() loggerMock.Object, appResourcesMock.Object, authenticationContextMock.Object, - sp + sp, + danClientMock.Object ); prefillToTest.PrefillDataModel(dataModel, externalPrefill, continueOnError: false); @@ -68,4 +78,62 @@ public async Task PrefillDataModel_AssignsValuesCorrectly() Assert.Equal("S'oderberg og Partners", dataModel.Prefill.YrkesskadeforsikringNavn); Assert.Equal("2023-12-31T12:00:00.000+01:00", dataModel.Prefill.YrkesskadeforsikringGyldigTilDato); } + + [Fact] + public async Task PrefillDataModel_Should_Fill_With_Data_From_Dan() + { + // Arrange + var dataModel = new PrefillDanTestDataModel(); + + var loggerMock = new Mock>(); + var appResourcesMock = new Mock(); + var authenticationContextMock = new Mock(); + var services = new ServiceCollection(); + var registryClientMock = new Mock(); + var danClientMock = new Mock(); + services.AddSingleton(registryClientMock.Object); + await using var sp = services.BuildStrictServiceProvider(); + + var prefillToTest = new PrefillSI( + loggerMock.Object, + appResourcesMock.Object, + authenticationContextMock.Object, + sp, + danClientMock.Object + ); + + var modelName = "model"; + var partyId = "1234"; + + // danData should match the data in the GetJsonConfig method + var danData = new Dictionary + { + { "BusinessAddressCity", "Email" }, + { "SectorCode", "OrganizationNumber" }, + }; + + var party = new Party() { PartyId = 1234, SSN = "12341234" }; + + appResourcesMock.Setup(ar => ar.GetPrefillJson(It.IsAny())).Returns(GetJsonConfig()); + registryClientMock + .Setup(m => m.GetPartyUnchecked(It.IsAny(), It.IsAny())) + .ReturnsAsync(party); + danClientMock + .Setup(m => m.GetDataset(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(danData); + + //Act + await prefillToTest.PrefillDataModel(partyId, modelName, dataModel); + + //Assert + danClientMock.Verify( + m => m.GetDataset(It.IsAny(), It.IsAny(), It.IsAny()), + Times.AtLeastOnce + ); + } + + private string GetJsonConfig() + { + return "{\n \"$schema\" : \"https://altinncdn.no/schemas/json/prefill/prefill.schema.v1.json\",\n \"allowOverwrite\" : true,\n \"DAN\" : {\n \"datasets\" : [ {\n \"name\" : \"UnitBasicInformation\",\n \"mappings\" : [ {\n \"BusinessAddressCity\" : \"Email\"\n }, {\n \"SectorCode\" : \"OrganizationNumber\"\n } ]\n } ]\n }\n}"; + } } diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index befc64ef92..22766a75ac 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -51,6 +51,12 @@ namespace Altinn.App.Core.Configuration public CacheSettings() { } public int ProfileCacheLifetimeSeconds { get; set; } } + public class DanSettings + { + public DanSettings() { } + public required string BaseUrl { get; set; } + public required string SubscriptionKey { get; set; } + } public class FrontEndSettings : System.Collections.Generic.Dictionary { public FrontEndSettings() { } @@ -2365,7 +2371,7 @@ namespace Altinn.App.Core.Implementation } public class PrefillSI : Altinn.App.Core.Internal.Prefill.IPrefill { - public PrefillSI(Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Internal.App.IAppResources appResourcesService, Altinn.App.Core.Features.Auth.IAuthenticationContext authenticationContext, System.IServiceProvider serviceProvider, Altinn.App.Core.Features.Telemetry? telemetry = null) { } + public PrefillSI(Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Internal.App.IAppResources appResourcesService, Altinn.App.Core.Features.Auth.IAuthenticationContext authenticationContext, System.IServiceProvider serviceProvider, Altinn.App.Core.Internal.Dan.IDanClient? danClient = null, Altinn.App.Core.Features.Telemetry? telemetry = null) { } public void PrefillDataModel(object dataModel, System.Collections.Generic.Dictionary externalPrefill, bool continueOnError = false) { } public System.Threading.Tasks.Task PrefillDataModel(string partyId, string dataModelName, object dataModel, System.Collections.Generic.Dictionary? externalPrefill = null) { } } @@ -2396,6 +2402,14 @@ namespace Altinn.App.Core.Infrastructure.Clients.Authorization public System.Threading.Tasks.Task ValidateSelectedParty(int userId, int partyId) { } } } +namespace Altinn.App.Core.Infrastructure.Clients.Dan +{ + public class DanClient : Altinn.App.Core.Internal.Dan.IDanClient + { + public DanClient(System.Net.Http.IHttpClientFactory factory, Microsoft.Extensions.Options.IOptions settings) { } + public System.Threading.Tasks.Task> GetDataset(string dataset, string subject, string fields) { } + } +} namespace Altinn.App.Core.Infrastructure.Clients.Events { public class EventsClient : Altinn.App.Core.Internal.Events.IEventsClient @@ -2989,6 +3003,13 @@ namespace Altinn.App.Core.Internal.Auth string GetUserToken(); } } +namespace Altinn.App.Core.Internal.Dan +{ + public interface IDanClient + { + System.Threading.Tasks.Task> GetDataset(string dataset, string subject, string fields); + } +} namespace Altinn.App.Core.Internal.Data { public static class FormDataWrapperFactory diff --git a/test/Altinn.App.Tests.Common/Fixtures/MockedServiceCollection.cs b/test/Altinn.App.Tests.Common/Fixtures/MockedServiceCollection.cs index 05c78079ac..b0d055e22b 100644 --- a/test/Altinn.App.Tests.Common/Fixtures/MockedServiceCollection.cs +++ b/test/Altinn.App.Tests.Common/Fixtures/MockedServiceCollection.cs @@ -6,10 +6,12 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.Serialization; +using Altinn.App.Core.Infrastructure.Clients.Dan; using Altinn.App.Core.Infrastructure.Clients.Storage; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; +using Altinn.App.Core.Internal.Dan; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Texts; @@ -98,6 +100,8 @@ public void TryAddCommonServices() _services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Storage); _services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Storage); + _services.TryAddSingleton(); + _services.TryAddSingleton(); _services.AddLogging(builder => {