From 3e5b4fe24e0df2a9da0675e849b24b83d96ed3fa Mon Sep 17 00:00:00 2001 From: Sondre Jahrsengene Date: Wed, 5 Mar 2025 10:06:21 +0100 Subject: [PATCH 1/2] temp implementation --- Dan.Core/Services/AvailableEvidenceCodesService.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Dan.Core/Services/AvailableEvidenceCodesService.cs b/Dan.Core/Services/AvailableEvidenceCodesService.cs index 3cba656..3a51aa3 100644 --- a/Dan.Core/Services/AvailableEvidenceCodesService.cs +++ b/Dan.Core/Services/AvailableEvidenceCodesService.cs @@ -1,4 +1,5 @@ -using Dan.Common.Models; +using System.Net.Http.Headers; +using Dan.Common.Models; using Dan.Core.Config; using Dan.Core.Extensions; using Dan.Core.Services.Interfaces; @@ -11,6 +12,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using AsyncKeyedLock; +using Azure.Identity; namespace Dan.Core.Services; @@ -205,9 +207,16 @@ private async Task AddServiceContextAuthorizationRequirements(List private async Task> GetEvidenceCodesFromSource(EvidenceSource source) { var client = _httpClientFactory.CreateClient(HttpClientName); - try { + // TEMP CODE pls + var creds = new DefaultAzureCredential(); + var token = await creds.GetTokenAsync(new Azure.Core.TokenRequestContext([ + ".default" + ])); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + // VERY TEMP ABOVE make handler first pls just for testing + var request = new HttpRequestMessage(HttpMethod.Get, source.Url); request.Headers.Add("Accept", "application/json"); var response = await client.SendAsync(request); From ecf929edbcf0bf97d7c52e45d0cc2f02e07484a5 Mon Sep 17 00:00:00 2001 From: Sondre Jahrsengene Date: Wed, 19 Mar 2025 14:16:21 +0100 Subject: [PATCH 2/2] feat: add support for azure auth to plugins --- Dan.Common/Constants.cs | 2 + Dan.Common/Enums/ErrorCode.cs | 10 ++ .../Extensions/HostBuilderExtensions.cs | 12 +- .../PluginAuthorizationMessageHandler.cs | 45 ++++++ Dan.Common/Services/DanPluginClientService.cs | 132 ++++++++++++++++++ Dan.Core/Program.cs | 21 ++- .../Services/AvailableEvidenceCodesService.cs | 8 -- Dan.Core/Services/EvidenceHarvesterService.cs | 8 +- Dan.Core/Services/EvidenceStatusService.cs | 5 +- Dan.PluginTest/Config/PluginConstants.cs | 2 + Dan.PluginTest/Config/Settings.cs | 2 +- Dan.PluginTest/Metadata.cs | 14 ++ Dan.PluginTest/Plugin.cs | 56 +++++++- 13 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 Dan.Common/Handlers/PluginAuthorizationMessageHandler.cs create mode 100644 Dan.Common/Services/DanPluginClientService.cs diff --git a/Dan.Common/Constants.cs b/Dan.Common/Constants.cs index 738aa91..667ca6b 100644 --- a/Dan.Common/Constants.cs +++ b/Dan.Common/Constants.cs @@ -14,6 +14,8 @@ public static class Constants public const string SafeHttpClient = "SafeHttpClient"; public const string SafeHttpClientPolicy = "SafeHttpClientPolicy"; + + public const string PluginHttpClient = "PluginHttpClient"; public const string LANGUAGE_CODE_NORWEGIAN_NB = "no-nb"; diff --git a/Dan.Common/Enums/ErrorCode.cs b/Dan.Common/Enums/ErrorCode.cs index 4a34b74..91dd08d 100644 --- a/Dan.Common/Enums/ErrorCode.cs +++ b/Dan.Common/Enums/ErrorCode.cs @@ -144,6 +144,16 @@ public enum ErrorCode /// An invalid JMES Path expression was supplied /// InvalidJmesPathExpressionException = 1027, + + /// + /// Exception from upstream + /// + UpstreamException = 1028, + + /// + /// Quota exceeded + /// + QuotaExceededException = 1029, //// //// Error codes for evidence source implementations (3xxx) diff --git a/Dan.Common/Extensions/HostBuilderExtensions.cs b/Dan.Common/Extensions/HostBuilderExtensions.cs index 03cd827..bdae81d 100644 --- a/Dan.Common/Extensions/HostBuilderExtensions.cs +++ b/Dan.Common/Extensions/HostBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Reflection; using Azure.Core.Serialization; +using Dan.Common.Handlers; using Dan.Common.Interfaces; using Dan.Common.Services; using Microsoft.Azure.Functions.Worker; @@ -51,7 +52,7 @@ public static IHostBuilder ConfigureDanPluginDefaults(this IHostBuilder builder) services.AddHttpClient(); services.AddApplicationInsightsTelemetryWorkerService(); services.ConfigureFunctionsApplicationInsights(); - + // You will need extra configuration because AI will only log per default Warning (default AI configuration). As this is a provider-specific // setting, it will override all non-provider (Logging:LogLevel)-based configurations. // https://github.com/microsoft/ApplicationInsights-dotnet/blob/main/NETCORE/src/Shared/Extensions/ApplicationInsightsExtensions.cs#L427 @@ -98,11 +99,20 @@ public static IHostBuilder ConfigureDanPluginDefaults(this IHostBuilder builder) services.AddHttpClient(Constants.SafeHttpClient, client => { client.Timeout = TimeSpan.FromSeconds(httpClientTimeoutSeconds); }) .AddPolicyHandlerFromRegistry(Constants.SafeHttpClientPolicy); + + // Using safehttpclient settings, but will add auth handler for talking with plugins + services.AddHttpClient(Constants.PluginHttpClient, + client => { client.Timeout = TimeSpan.FromSeconds(httpClientTimeoutSeconds); }) + .AddPolicyHandlerFromRegistry(Constants.SafeHttpClientPolicy) + .AddHttpMessageHandler(); // Add a common service to fetch information from the CCR ("Enhetsregisteret"). Using a default API-client (which just wraps a HttpClient), which // calls a proxy in Core by default. Core uses the same service, but a different IEntityRegistryApiClientService which utilizes a distributed cache. services.AddSingleton(); services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); // Try to add the first IEvidenceSourceMetadata implementation we can find in the entry assembly var evidenceSourceMetadataServiceType = typeof(IEvidenceSourceMetadata); diff --git a/Dan.Common/Handlers/PluginAuthorizationMessageHandler.cs b/Dan.Common/Handlers/PluginAuthorizationMessageHandler.cs new file mode 100644 index 0000000..0d38794 --- /dev/null +++ b/Dan.Common/Handlers/PluginAuthorizationMessageHandler.cs @@ -0,0 +1,45 @@ +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Configuration; + +namespace Dan.Common.Handlers; + +// Source: https://stackoverflow.com/questions/71187362/azure-function-to-azure-function-request-using-defaultazurecredential-and-httpcl +/// +/// Adds bearer token with azure credentials for scope +/// +public class PluginAuthorizationMessageHandler : DelegatingHandler +{ + private readonly IConfiguration _configuration; + private readonly DefaultAzureCredential _credentials; + + /// + /// Base constructor for azure credential handler + /// + public PluginAuthorizationMessageHandler(IConfiguration configuration) + { + _configuration = configuration; + _credentials = new DefaultAzureCredential(); + } + + /// + /// Adds bearer token to http request + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Usually just need one scope, api://{{guid}}/.default, but supports more for special cases + var scopes = _configuration.GetSection("PluginScopes").Value?.Split(","); + + // No scopes? We just send. If something requires a scope, then add that to app settings + if (scopes is not { Length: > 0 }) + { + return await base.SendAsync(request, cancellationToken); + } + + var tokenRequestContext = new TokenRequestContext(scopes); + var tokenResult = await _credentials.GetTokenAsync(tokenRequestContext, cancellationToken); + var authorizationHeader = new AuthenticationHeaderValue("Bearer", tokenResult.Token); + request.Headers.Authorization = authorizationHeader; + return await base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/Dan.Common/Services/DanPluginClientService.cs b/Dan.Common/Services/DanPluginClientService.cs new file mode 100644 index 0000000..496f682 --- /dev/null +++ b/Dan.Common/Services/DanPluginClientService.cs @@ -0,0 +1,132 @@ +using System.Text; +using Dan.Common.Exceptions; + +namespace Dan.Common.Services; + +/// +/// Service for calling DAN plugins +/// +public interface IDanPluginClientService +{ + /// + /// Get dataset value + /// + public Task GetPluginDataSetAsync(EvidenceHarvesterRequest request, string code, string env, bool isDefaultJson, string url = "", string source = "") where T: new(); +} + +/// +/// Service for calling DAN plugins +/// +public class DanPluginClientService : IDanPluginClientService +{ + private readonly HttpClient _httpClient; + private const string MetadataDev = "https://dev-api.data.altinn.no/v1/public/metadata/evidencecodes"; + private const string MetadataTest = "https://test-api.data.altinn.no/v1/public/metadata/evidencecodes"; + private const string MetadataProd = "https://api.data.altinn.no/v1/public/metadata/evidencecodes"; + private const string PluginDev = "https://func-es{0}-test-dev.azurewebsites.net/api/{1}?code={2}"; + private const string PluginTest = "https://func-es{0}-prod-prod-staging.azurewebsites.net/api/{1}?code={2}"; + private const string PluginProd = "https://func-es{0}-prod-prod.azurewebsites.net/api/{1}?code={2}"; + + /// + /// Sets up service with a safe http client built from provided factory + /// + public DanPluginClientService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(Constants.PluginHttpClient); + } + + /// + /// Get dataset value + /// + public async Task GetPluginDataSetAsync(EvidenceHarvesterRequest request, string code, string env, bool isDefaultJson, string pluginUrl = "", string source = "") where T : new() + { + HttpResponseMessage? response = default; + var result = default(T); + try + { + var url = env switch + { + "dev" => MetadataDev, + "test" => MetadataTest, + "prod" => MetadataProd, + _ => throw new ArgumentOutOfRangeException(nameof(env), "env must be dev, test or prod"), + }; + + var metadataResponse = await _httpClient.GetAsync(url); + + EvidenceCode? evidenceCode; + if (metadataResponse.IsSuccessStatusCode) + { + var evidenceCodes = JsonConvert.DeserializeObject>(await metadataResponse.Content.ReadAsStringAsync()); + evidenceCode = evidenceCodes?.FirstOrDefault(x => x.EvidenceCodeName == request.EvidenceCodeName); + if (evidenceCode == null) + { + throw new EvidenceSourceTransientException(1, "Dataset not found"); + } + if (!string.IsNullOrEmpty(evidenceCode.RequiredScopes)) + { + throw new ArgumentOutOfRangeException(nameof(request.MPToken), $"Dataset requires maskinporten token which was not supplied"); + } + } + else + { + throw new EvidenceSourceTransientException(1, "Dataset not found"); + } + pluginUrl = string.IsNullOrEmpty(pluginUrl) ? GetPluginUrl(env, source, evidenceCode.EvidenceCodeName, code) : pluginUrl; + response = await _httpClient.PostAsync(pluginUrl, new StringContent(JsonConvert.SerializeObject(request),Encoding.UTF8, "application/json")); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + { + var content = await response.Content.ReadAsStringAsync(); + if (isDefaultJson) + { + var evidenceValues = JsonConvert.DeserializeObject>(content); + var evidenceValue = evidenceValues?.First(x => x.EvidenceValueName == "default"); + if (evidenceValue?.Value is null) + { + return result; + } + + var stringvalue = evidenceValue.Value.ToString(); + result = JsonConvert.DeserializeObject(stringvalue!); + } + else + { + result = JsonConvert.DeserializeObject(content); + } + } + break; + case HttpStatusCode.NoContent: + throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Unexpected HTTP status code from external API, no content found"); + case HttpStatusCode.BadRequest: + throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Bad request"); + case HttpStatusCode.UnprocessableEntity: + throw new EvidenceSourceTransientException((int)ErrorCode.QuotaExceededException, "Quota exceeded. Try again tomorrow."); + default: + throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Unexpected HTTP status code from external API"); + } + } + catch (HttpRequestException ex) + { + throw new EvidenceSourcePermanentServerException((int)ErrorCode.UpstreamException, null, ex); + } + finally + { + response?.Dispose(); + } + return result; + } + + private static string GetPluginUrl(string env, string source, string evidenceCodeName, string code) + { + var result = env switch + { + "dev" => string.Format(PluginDev, source, evidenceCodeName, code), + "test" => string.Format(PluginTest, source, evidenceCodeName, code), + "prod" => string.Format(PluginProd, source, evidenceCodeName, code), + _ => throw new ArgumentOutOfRangeException(nameof(env), $"env must be dev, test or prod"), + }; + return result; + } +} \ No newline at end of file diff --git a/Dan.Core/Program.cs b/Dan.Core/Program.cs index 19e407f..5359765 100644 --- a/Dan.Core/Program.cs +++ b/Dan.Core/Program.cs @@ -1,5 +1,7 @@ using System.Reflection; using Azure.Core.Serialization; +using Dan.Common; +using Dan.Common.Handlers; using Dan.Common.Interfaces; using Dan.Common.Models; using Dan.Common.Services; @@ -109,8 +111,9 @@ services.AddScoped(); services.AddScoped(); services.AddScoped(); - + services.AddTransient(); + services.AddTransient(); services.AddPolicyRegistry(new PolicyRegistry() { @@ -140,14 +143,23 @@ } }); - // Default client to use in harvesting - services.AddHttpClient("SafeHttpClient", client => + // Default clients to use in harvesting + services.AddHttpClient(Constants.SafeHttpClient, client => { client.DefaultRequestHeaders.Add("Accept", "application/json"); client.BaseAddress = new Uri(Settings.ApiUrl); }) .AddPolicyHandlerFromRegistry("DefaultCircuitBreaker") .AddHttpMessageHandler(); + + services.AddHttpClient(Constants.PluginHttpClient, client => + { + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.BaseAddress = new Uri(Settings.ApiUrl); + }) + .AddPolicyHandlerFromRegistry("DefaultCircuitBreaker") + .AddHttpMessageHandler() + .AddHttpMessageHandler(); // Client used for getting evidence code lists from data sources services.AddHttpClient("EvidenceCodesClient", client => @@ -155,7 +167,8 @@ client.DefaultRequestHeaders.Add("Accept", "application/json"); client.Timeout = TimeSpan.FromSeconds(25); }) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .AddHttpMessageHandler(); // Client with enterprise certificate authentication services.AddHttpClient("ECHttpClient", client => diff --git a/Dan.Core/Services/AvailableEvidenceCodesService.cs b/Dan.Core/Services/AvailableEvidenceCodesService.cs index 3a51aa3..d92bbef 100644 --- a/Dan.Core/Services/AvailableEvidenceCodesService.cs +++ b/Dan.Core/Services/AvailableEvidenceCodesService.cs @@ -209,14 +209,6 @@ private async Task> GetEvidenceCodesFromSource(EvidenceSource var client = _httpClientFactory.CreateClient(HttpClientName); try { - // TEMP CODE pls - var creds = new DefaultAzureCredential(); - var token = await creds.GetTokenAsync(new Azure.Core.TokenRequestContext([ - ".default" - ])); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - // VERY TEMP ABOVE make handler first pls just for testing - var request = new HttpRequestMessage(HttpMethod.Get, source.Url); request.Headers.Add("Accept", "application/json"); var response = await client.SendAsync(request); diff --git a/Dan.Core/Services/EvidenceHarvesterService.cs b/Dan.Core/Services/EvidenceHarvesterService.cs index d2aa530..a281c6f 100644 --- a/Dan.Core/Services/EvidenceHarvesterService.cs +++ b/Dan.Core/Services/EvidenceHarvesterService.cs @@ -1,4 +1,4 @@ -using System.Net; +using Dan.Common; using Dan.Common.Enums; using Dan.Common.Models; using Dan.Core.Config; @@ -113,7 +113,7 @@ public async Task HarvestOpenData(EvidenceCode evidenceCode, string id try { - var client = _httpClientFactory.CreateClient("SafeHttpClient"); + var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient); harvestedEvidence = (await EvidenceSourceHelper.DoRequest>( request, () => client.SendAsync(request, cts.Token)))!; @@ -146,7 +146,7 @@ private async Task HarvestEvidenceStream(EvidenceCode evidenceCode, Accr request.SetPolicyExecutionContext(new Context(request.Key(CacheArea.Absolute))); try { - var client = _httpClientFactory.CreateClient("SafeHttpClient"); + var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient); // When attempting to stream from the evidence source, we simplify error handling var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token); @@ -176,7 +176,7 @@ private async Task> HarvestEvidenceValues(EvidenceCode evide request.SetPolicyExecutionContext(new Context(request.Key(CacheArea.Absolute))); try { - var client = _httpClientFactory.CreateClient("SafeHttpClient"); + var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient); return (await EvidenceSourceHelper.DoRequest>( request, () => client.SendAsync(request, cts.Token)))!; diff --git a/Dan.Core/Services/EvidenceStatusService.cs b/Dan.Core/Services/EvidenceStatusService.cs index 82d16c6..4317130 100644 --- a/Dan.Core/Services/EvidenceStatusService.cs +++ b/Dan.Core/Services/EvidenceStatusService.cs @@ -1,4 +1,5 @@ -using Dan.Common.Enums; +using Dan.Common; +using Dan.Common.Enums; using Dan.Common.Models; using Dan.Core.Extensions; using Dan.Core.Helpers; @@ -168,7 +169,7 @@ private async Task GetAsynchronousEvidenceStatusCode(Accredi }; request.JsonContent(evidenceHarvesterRequest); - var client = _clientFactory.CreateClient("SafeHttpClient"); + var client = _clientFactory.CreateClient(Constants.PluginHttpClient); return (await EvidenceSourceHelper.DoRequest( request, diff --git a/Dan.PluginTest/Config/PluginConstants.cs b/Dan.PluginTest/Config/PluginConstants.cs index 6445d0c..6f689c5 100644 --- a/Dan.PluginTest/Config/PluginConstants.cs +++ b/Dan.PluginTest/Config/PluginConstants.cs @@ -10,4 +10,6 @@ public static class PluginConstants public const string Source = "Source"; public const string DatasetOne = "DatasetOne"; public const string DatasetTwo = "DatasetTwo"; + + public const string PluginForward = "PluginForward"; } \ No newline at end of file diff --git a/Dan.PluginTest/Config/Settings.cs b/Dan.PluginTest/Config/Settings.cs index f2dfa8a..aaabb8c 100644 --- a/Dan.PluginTest/Config/Settings.cs +++ b/Dan.PluginTest/Config/Settings.cs @@ -2,5 +2,5 @@ public class Settings { - + public string PluginCode { get; set; } } \ No newline at end of file diff --git a/Dan.PluginTest/Metadata.cs b/Dan.PluginTest/Metadata.cs index 09926e0..fdfa44d 100644 --- a/Dan.PluginTest/Metadata.cs +++ b/Dan.PluginTest/Metadata.cs @@ -69,6 +69,20 @@ public List GetEvidenceCodes() .ToJson(Newtonsoft.Json.Formatting.Indented) } ] + }, + new EvidenceCode + { + EvidenceCodeName = PluginConstants.PluginForward, + EvidenceSource = PluginConstants.Source, + ServiceContext = DanTest, + Values = + [ + new EvidenceValue + { + EvidenceValueName = "default", + ValueType = EvidenceValueType.JsonSchema + } + ] } ]; } diff --git a/Dan.PluginTest/Plugin.cs b/Dan.PluginTest/Plugin.cs index e7656a9..9f78b16 100644 --- a/Dan.PluginTest/Plugin.cs +++ b/Dan.PluginTest/Plugin.cs @@ -1,22 +1,28 @@ using Dan.Common.Exceptions; using Dan.Common.Interfaces; using Dan.Common.Models; +using Dan.Common.Services; using Dan.Common.Util; using Dan.PluginTest.Config; using Dan.PluginTest.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Dan.PluginTest; // ReSharper disable once ClassNeverInstantiated.Global public class Plugin( ILoggerFactory loggerFactory, - IEvidenceSourceMetadata evidenceSourceMetadata) + IEvidenceSourceMetadata evidenceSourceMetadata, + IDanPluginClientService danPluginClientService, + IOptions settings) { private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly Settings _settings = settings.Value; + // Used to test aliases [Function(PluginConstants.DatasetOne)] public async Task DatasetOne( [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequestData req, @@ -40,6 +46,7 @@ public async Task DatasetOne( () => GetEvidenceValuesDatasetOne(evidenceHarvesterRequest)); } + // Used to test aliases [Function(PluginConstants.DatasetTwo)] public async Task DatasetTwo( [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequestData req, @@ -63,6 +70,30 @@ public async Task DatasetTwo( () => GetEvidenceValuesDatasetTwo(evidenceHarvesterRequest)); } + // Used to test IDanPluginClientService for plugin to plugic calls + [Function(PluginConstants.PluginForward)] + public async Task PluginForward( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequestData req, + FunctionContext context) + { + EvidenceHarvesterRequest? evidenceHarvesterRequest; + try + { + evidenceHarvesterRequest = await req.ReadFromJsonAsync(); + } + catch (Exception e) + { + _logger.LogError(e, + "Exception while attempting to parse request into EvidenceHarvesterRequest: {exceptionType}: {exceptionMessage}", + e.GetType().Name, e.Message); + throw new EvidenceSourcePermanentClientException(PluginConstants.ErrorInvalidInput, + "Unable to parse request", e); + } + + return await EvidenceSourceResponse.CreateResponse(req, + () => FetchPluginValue(evidenceHarvesterRequest)); + } + private async Task> GetEvidenceValuesDatasetOne( EvidenceHarvesterRequest? evidenceHarvesterRequest) { @@ -78,10 +109,31 @@ private async Task> GetEvidenceValuesDatasetTwo( EvidenceHarvesterRequest? evidenceHarvesterRequest) { var response = new DatasetResponse{Test = "DatasetTwo"}; - var ecb = new EvidenceBuilder(evidenceSourceMetadata, PluginConstants.DatasetOne); + var ecb = new EvidenceBuilder(evidenceSourceMetadata, PluginConstants.DatasetTwo); ecb.AddEvidenceValue("default", response, PluginConstants.Source); await Task.CompletedTask; return ecb.GetEvidenceValues(); } + + private async Task> FetchPluginValue( + EvidenceHarvesterRequest? evidenceHarvesterRequest) + { + var enovaRequest = new EvidenceHarvesterRequest + { + EvidenceCodeName = "OffentligEnergiData", + OrganizationNumber = "931001434" + }; + + var response = await danPluginClientService.GetPluginDataSetAsync( + request: enovaRequest, + code: _settings.PluginCode, + env: "dev", + isDefaultJson: true, + source: "enova"); + var ecb = new EvidenceBuilder(evidenceSourceMetadata, PluginConstants.DatasetOne); + ecb.AddEvidenceValue("default", response, PluginConstants.Source); + + return ecb.GetEvidenceValues(); + } } \ No newline at end of file