diff --git a/src/APIM.sln b/src/APIM.sln index e3b02d8e07..fabd91055a 100644 --- a/src/APIM.sln +++ b/src/APIM.sln @@ -504,6 +504,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.SharedOuterApi.Appr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.SharedOuterApi.Apprentice.GovUK.Auth.UnitTests", "Shared\SFA.DAS.SharedOuterApi.Apprentice.GovUK.Auth.UnitTests\SFA.DAS.SharedOuterApi.Apprentice.GovUK.Auth.UnitTests.csproj", "{86F8DB4C-5EB3-4B5E-A232-7C09095ADEA2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Payments", "Payments", "{1AC73DE4-60FE-4E67-82DC-05656F74D7B3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments", "Payments\SFA.DAS.Payments\SFA.DAS.Payments.csproj", "{20D6BE73-2729-4B9D-BC69-C60663964486}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.Api", "Payments\SFA.DAS.Payments.Api\SFA.DAS.Payments.Api.csproj", "{91EA5AEF-58FB-4930-9736-FE05E6DFBF64}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.Api.UnitTests", "Payments\SFA.DAS.Payments.Api.UnitTests\SFA.DAS.Payments.Api.UnitTests.csproj", "{9C81947D-6B60-4019-A799-9B66F2166D1F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.UnitTests", "Payments\SFA.DAS.Payments.UnitTests\SFA.DAS.Payments.UnitTests.csproj", "{5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1318,6 +1328,22 @@ Global {86F8DB4C-5EB3-4B5E-A232-7C09095ADEA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {86F8DB4C-5EB3-4B5E-A232-7C09095ADEA2}.Release|Any CPU.ActiveCfg = Release|Any CPU {86F8DB4C-5EB3-4B5E-A232-7C09095ADEA2}.Release|Any CPU.Build.0 = Release|Any CPU + {20D6BE73-2729-4B9D-BC69-C60663964486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20D6BE73-2729-4B9D-BC69-C60663964486}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20D6BE73-2729-4B9D-BC69-C60663964486}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20D6BE73-2729-4B9D-BC69-C60663964486}.Release|Any CPU.Build.0 = Release|Any CPU + {91EA5AEF-58FB-4930-9736-FE05E6DFBF64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91EA5AEF-58FB-4930-9736-FE05E6DFBF64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91EA5AEF-58FB-4930-9736-FE05E6DFBF64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91EA5AEF-58FB-4930-9736-FE05E6DFBF64}.Release|Any CPU.Build.0 = Release|Any CPU + {9C81947D-6B60-4019-A799-9B66F2166D1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C81947D-6B60-4019-A799-9B66F2166D1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C81947D-6B60-4019-A799-9B66F2166D1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C81947D-6B60-4019-A799-9B66F2166D1F}.Release|Any CPU.Build.0 = Release|Any CPU + {5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1525,6 +1551,10 @@ Global {F014EBD4-A9FA-4196-A39C-BD71805449D6} = {2FBDD130-2DBC-4BBB-8219-D9AF89DE0BF8} {3BA94F50-4E8B-463E-87FA-03A9C060FC36} = {9C327515-DF6A-4277-BF53-4C86D510C718} {86F8DB4C-5EB3-4B5E-A232-7C09095ADEA2} = {9C327515-DF6A-4277-BF53-4C86D510C718} + {20D6BE73-2729-4B9D-BC69-C60663964486} = {1AC73DE4-60FE-4E67-82DC-05656F74D7B3} + {91EA5AEF-58FB-4930-9736-FE05E6DFBF64} = {1AC73DE4-60FE-4E67-82DC-05656F74D7B3} + {9C81947D-6B60-4019-A799-9B66F2166D1F} = {1AC73DE4-60FE-4E67-82DC-05656F74D7B3} + {5F6EE783-C6B9-4E8B-BA5F-C2CD327245A5} = {1AC73DE4-60FE-4E67-82DC-05656F74D7B3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2353A4D0-2859-4472-B2D4-BBD8087B4E22} diff --git a/src/Payments/README.md b/src/Payments/README.md new file mode 100644 index 0000000000..3bf8b624c3 --- /dev/null +++ b/src/Payments/README.md @@ -0,0 +1,54 @@ +## ⛔Never push sensitive information such as client id's, secrets or keys into repositories including in the README file⛔ + +# Payments Outer API + +UK Government logo + +[![Build Status](https://dev.azure.com/sfa-gov-uk/Digital%20Apprenticeship%20Service/_apis/build/status/das-apim-endpoints-Apprenticeships?branchName=master)](https://dev.azure.com/sfa-gov-uk/Digital%20Apprenticeship%20Service/_build/latest?definitionId=das-apim-endpoints-Payments&branchName=master) +[![Jira Project](https://img.shields.io/badge/Jira-Project-blue)](https://skillsfundingagency.atlassian.net/jira/software/c/projects/FLP/boards/753) +[![Confluence Project](https://img.shields.io/badge/Confluence-Project-blue)](https://skillsfundingagency.atlassian.net/wiki/spaces/NDL/pages/3480354918/Flexible+Payments+Models) +[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?longCache=true&style=flat-square)](https://en.wikipedia.org/wiki/MIT_License) + +The Payments outer provides endpoints used by das-funding-payments and should not be used by any other applications. + +## How It Works + +The Outer API orchestrates calls to multiple inner APIs in order to provide the functionality required by the UI. Each UI page operation (GET, POST etc) should call a single outer API endpoint only. + +## 🚀 Installation + +### Pre-Requisites + +* A clone of this repository +* A code editor that supports .Net8 +* Azure Storage Emulator (Azureite) + +### Config + +Most of the application configuration is taken from the [das-employer-config repository](https://github.com/SkillsFundingAgency/das-employer-config) and the default values can be used in most cases. The config json will need to be added to the local Azure Storage instance with a a PartitionKey of LOCAL and a RowKey of SFA.DAS.Payments.OuterAPI_1.0. + +| Name | Description | Stub Value | +| ------------------------------------------------------------- | ------------------------------------------------- |-------------------------------------------| +| LearnerDataApiConfiguration:Url | Url of the data endpoint | https://localhost:4000/learner-data-api | +| LearnerDataApiConfiguration:TokenSettings:Url | Url of the token endpoint | | +| LearnerDataApiConfiguration:TokenSettings:Scope | Token settings | | +| LearnerDataApiConfiguration:TokenSettings:ClientId | Token settings | | +| LearnerDataApiConfiguration:TokenSettings:Tenant | Token settings | | +| LearnerDataApiConfiguration:TokenSettings:ClientSecret | Token settings | | +| LearnerDataApiConfiguration:TokenSettings:GrantType | Token settings | | +| LearnerDataApiConfiguration:TokenSettings:ShouldSkipForLocal | For local use only, skips calling token endpoint | true | + + + + +## 🔗 External Dependencies + +The Outer API has many external dependancies which can all be configured to use stubs by following the config above. + +## Running Locally + +* Make sure Azure Storage Emulator (Azureite) is running +* Make sure the config has been updated to call any Stub APIs required +* Run the [Commitments Stubs](https://github.com/SkillsFundingAgency/das-commitments-stubs) +* Run the Apprenticeships Inner API +* Run the application \ No newline at end of file diff --git a/src/Payments/SFA.DAS.Payments.Api.UnitTests/AssertExtensions.cs b/src/Payments/SFA.DAS.Payments.Api.UnitTests/AssertExtensions.cs new file mode 100644 index 0000000000..afe6479497 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api.UnitTests/AssertExtensions.cs @@ -0,0 +1,18 @@ +using FluentAssertions; + +namespace SFA.DAS.Payments.Api.UnitTests +{ + public static class AssertExtensions + { + //extension that asserts that the object is of the expected type and returns it + public static T ShouldBeOfType(this object? actual) + { + if (actual == null) + throw new AssertionException($"Expected object of type {typeof(T).Name} but was null"); + + actual.Should().BeOfType(); + return (T)actual; + } + + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/CollectionCalendar/WhenGettingAcademicYear.cs b/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/CollectionCalendar/WhenGettingAcademicYear.cs new file mode 100644 index 0000000000..4fb708f600 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/CollectionCalendar/WhenGettingAcademicYear.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SFA.DAS.Payments.Api.Controllers; +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.InnerApi.Requests.CollectionCalendar; +using SFA.DAS.SharedOuterApi.InnerApi.Responses.CollectionCalendar; +using SFA.DAS.SharedOuterApi.Interfaces; +using SFA.DAS.Testing.AutoFixture; + +namespace SFA.DAS.Payments.Api.UnitTests.Controllers.CollectionCalendar; + +public class WhenGettingAcademicYear +{ + [Test, MoqAutoData] + public async Task Then_Gets_ApprenticeshipKey_From_ApiClient( + GetAcademicYearsResponse expectedResponse, + DateTime searchDate, + Mock> mockCollectionCalendarApiClient) + { + // Arrange + mockCollectionCalendarApiClient.Setup(x => x.Get(It.IsAny())).ReturnsAsync(expectedResponse); + + var controller = new CollectionCalendarController(mockCollectionCalendarApiClient.Object); + + // Act + var result = await controller.GetAcademicYear(searchDate); + + // Assert + var okObjectResult = result.ShouldBeOfType(); + var actualResponse = okObjectResult.Value.ShouldBeOfType(); + actualResponse.Should().Be(expectedResponse); + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/WhenGetLearnerReferences.cs b/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/WhenGetLearnerReferences.cs new file mode 100644 index 0000000000..c87c5e9cae --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api.UnitTests/Controllers/WhenGetLearnerReferences.cs @@ -0,0 +1,71 @@ +using AutoFixture; +using FluentAssertions; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SFA.DAS.Payments.Api.Controllers; +using SFA.DAS.Payments.Api.Models; +using SFA.DAS.Payments.Application.Learners; +using SFA.DAS.Payments.Models.Responses; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SFA.DAS.Payments.Api.UnitTests.Controllers +{ + [TestFixture] + internal class WhenGetLearnerReferences + { +#pragma warning disable CS8618 // Non-nullable field is uninitialized. - nUnit initializes fields in SetUp + private Fixture _fixture; + private Mock> _loggerMock; + private Mock _mediatorMock; + private IlrController _controller; +#pragma warning restore CS8618 // Non-nullable field is uninitialized. + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _loggerMock = new Mock>(); + _mediatorMock = new Mock(); + _controller = new IlrController(_loggerMock.Object, _mediatorMock.Object); + } + + [Test] + public async Task Then_ReturnsOkResult_WithLearnerReferences() + { + // Arrange + var ukprn = _fixture.Create(); + var academicYear = _fixture.Create(); + var learnersQueryResult = _fixture.Create>(); + + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)).ReturnsAsync(learnersQueryResult); + + // Act + var result = await _controller.GetLearnerReferences(ukprn, academicYear); + + // Assert + result.Should().BeOfType(); + var okResult = result.Should().BeOfType().Subject; + okResult.Value.Should().BeEquivalentTo(learnersQueryResult.ToLearnerReferenceResponse()); + } + + [Test] + public async Task Then_ReturnsBadRequest_OnException() + { + // Arrange + var ukprn = _fixture.Create(); + var academicYear = _fixture.Create(); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)) + .ThrowsAsync(new Exception("Test exception")); + + // Act + var result = await _controller.GetLearnerReferences(ukprn, academicYear); + + // Assert + result.Should().BeOfType(); + } + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api.UnitTests/SFA.DAS.Payments.Api.UnitTests.csproj b/src/Payments/SFA.DAS.Payments.Api.UnitTests/SFA.DAS.Payments.Api.UnitTests.csproj new file mode 100644 index 0000000000..605a807daa --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api.UnitTests/SFA.DAS.Payments.Api.UnitTests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/src/Payments/SFA.DAS.Payments.Api/AppStart/AddServiceRegistrationExtensions.cs b/src/Payments/SFA.DAS.Payments.Api/AppStart/AddServiceRegistrationExtensions.cs new file mode 100644 index 0000000000..fcb96d9ada --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/AppStart/AddServiceRegistrationExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Options; +using SFA.DAS.Api.Common.Configuration; +using SFA.DAS.Api.Common.Infrastructure; +using SFA.DAS.Api.Common.Interfaces; +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.Infrastructure; +using SFA.DAS.SharedOuterApi.Interfaces; +using SFA.DAS.SharedOuterApi.Services; +using System.Diagnostics.CodeAnalysis; + +namespace SFA.DAS.Payments.Api.AppStart; + +[ExcludeFromCodeCoverage] +public static class AddServiceRegistrationExtensions +{ + public static void AddServiceRegistration(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient(); + services.AddTransient(); + services.AddTransient(typeof(IInternalApiClient<>), typeof(InternalApiClient<>)); + services.AddTransient(typeof(IAccessTokenApiClient<>), typeof(AccessTokenApiClient<>)); + services.AddTransient, LearnerDataApiClient>(); + services.AddTransient, CollectionCalendarApiClient>(); + } +} + +[ExcludeFromCodeCoverage] +public static class AddConfigurationOptionsExtension +{ + public static void AddConfigurationOptions(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions(); + + services.Configure(configuration.GetSection("AzureAd")); + services.AddSingleton(cfg => cfg.GetService>()!.Value); + + services.Configure(configuration.GetSection(nameof(LearnerDataApiConfiguration))); + services.AddSingleton(cfg => cfg.GetService>()!.Value); + + services.Configure(configuration.GetSection(nameof(CollectionCalendarApiConfiguration))); + services.AddSingleton(cfg => cfg.GetService>()!.Value); + } + +} \ No newline at end of file diff --git a/src/Payments/SFA.DAS.Payments.Api/Controllers/CollectionCalendarController.cs b/src/Payments/SFA.DAS.Payments.Api/Controllers/CollectionCalendarController.cs new file mode 100644 index 0000000000..aea21e5a33 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Controllers/CollectionCalendarController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.InnerApi.Requests.CollectionCalendar; +using SFA.DAS.SharedOuterApi.InnerApi.Responses.CollectionCalendar; +using SFA.DAS.SharedOuterApi.Interfaces; + +namespace SFA.DAS.Payments.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class CollectionCalendarController : ControllerBase +{ + private readonly ICollectionCalendarApiClient _collectionCalendarApiClient; + + public CollectionCalendarController(ICollectionCalendarApiClient collectionCalendarApiClient) + { + _collectionCalendarApiClient = collectionCalendarApiClient; + } + + [HttpGet] + [Route("academicYear/{searchDate}")] + public async Task GetAcademicYear(DateTime searchDate) + { + return Ok(await _collectionCalendarApiClient.Get(new GetAcademicYearByDateRequest(searchDate))); + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api/Controllers/IlrController.cs b/src/Payments/SFA.DAS.Payments.Api/Controllers/IlrController.cs new file mode 100644 index 0000000000..2054b40235 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Controllers/IlrController.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SFA.DAS.Payments.Api.Models; +using SFA.DAS.Payments.Application.Learners; + +namespace SFA.DAS.Payments.Api.Controllers +{ + [ApiController] + [Route("ILR")] + public class IlrController : ControllerBase + { + private readonly ILogger _logger; + private readonly IMediator _mediator; + + public IlrController(ILogger logger, IMediator mediator) + { + _logger = logger; + _mediator = mediator; + } + + [HttpGet] + [Route("{ukprn}/{academicYear}")] + public async Task GetLearnerReferences(string ukprn, short academicYear) + { + try + { + var result = await _mediator.Send(new GetLearnersQuery(ukprn, academicYear)); + var learnerReferences = result.ToLearnerReferenceResponse(); + return Ok(learnerReferences); + } + catch(Exception e) + { + _logger.LogError(e, "Error attempting to get Learner References"); + return BadRequest(); + } + } + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api/Models/LearnerReferencesResponse.cs b/src/Payments/SFA.DAS.Payments.Api/Models/LearnerReferencesResponse.cs new file mode 100644 index 0000000000..2bfce12d75 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Models/LearnerReferencesResponse.cs @@ -0,0 +1,18 @@ +using SFA.DAS.Payments.Models.Responses; + +namespace SFA.DAS.Payments.Api.Models +{ + public class LearnerReferenceResponse + { + public long Uln { get; set; } + public string LearnerRefNumber { get; set; } = string.Empty; + } + + public static class LearnerReferenceResponseExtensions + { + public static IEnumerable ToLearnerReferenceResponse(this IEnumerable learners) + { + return learners.Select(x=> new LearnerReferenceResponse { Uln = x.Uln, LearnerRefNumber = x.LearnRefNumber }); + } + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api/Program.cs b/src/Payments/SFA.DAS.Payments.Api/Program.cs new file mode 100644 index 0000000000..67e2944f6b --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Program.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SFA.DAS.Payments.Api; + +[ExcludeFromCodeCoverage] +public class Program +{ + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); +} \ No newline at end of file diff --git a/src/Payments/SFA.DAS.Payments.Api/Properties/launchSettings.json b/src/Payments/SFA.DAS.Payments.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..7850330879 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53085", + "sslPort": 44373 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7157;http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api/SFA.DAS.Payments.Api.csproj b/src/Payments/SFA.DAS.Payments.Api/SFA.DAS.Payments.Api.csproj new file mode 100644 index 0000000000..102f2657cd --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/SFA.DAS.Payments.Api.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Payments/SFA.DAS.Payments.Api/Startup.cs b/src/Payments/SFA.DAS.Payments.Api/Startup.cs new file mode 100644 index 0000000000..866c231a17 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/Startup.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.OpenApi.Models; +using SFA.DAS.Api.Common.AppStart; +using SFA.DAS.Api.Common.Configuration; +using SFA.DAS.Payments.Api.AppStart; +using SFA.DAS.Payments.Application.Learners; +using SFA.DAS.SharedOuterApi.AppStart; +using SFA.DAS.SharedOuterApi.Infrastructure.HealthCheck; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace SFA.DAS.Payments.Api; + +[ExcludeFromCodeCoverage] +public class Startup +{ + private readonly IWebHostEnvironment _env; + private readonly IConfiguration _configuration; + + public Startup(IConfiguration configuration, IWebHostEnvironment env) + { + _env = env; + _configuration = configuration.BuildSharedConfiguration(); + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + services.AddSingleton(_env); + + services.AddConfigurationOptions(_configuration); + + if (!_configuration.IsLocalOrDev()) + { + var azureAdConfiguration = _configuration + .GetSection("AzureAd") + .Get(); + var policies = new Dictionary + { + {"default", "APIM"} + }; + + services.AddAuthentication(azureAdConfiguration, policies); + } + + services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly(typeof(GetLearnersQueryHandler).Assembly)); + services.AddServiceRegistration(_configuration); + + services + .AddMvc(o => + { + if (!_configuration.IsLocalOrDev()) + { + o.Filters.Add(new AuthorizeFilter("default")); + } + }); + + if (_configuration["Environment"] != "DEV") + { + services.AddHealthChecks() + .AddCheck(LocationsApiHealthCheck.HealthCheckResultDescription); + } + + services.AddControllers().AddJsonOptions(options => + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + + services.AddOpenTelemetryRegistration(_configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]); + + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "PaymentsOuterApi", Version = "v1" }); + }); + + services.AddLogging(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/Error"); + } + + app.UseAuthentication(); + + if (!_configuration["Environment"].Equals("DEV", StringComparison.CurrentCultureIgnoreCase)) + { + app.UseHealthChecks(); + } + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute( + name: "default", + pattern: "{controller}/{action}/{id}"); + }); + + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "PaymentsOuterApi"); + c.RoutePrefix = string.Empty; + }); + } +} diff --git a/src/Payments/SFA.DAS.Payments.Api/appsettings.json b/src/Payments/SFA.DAS.Payments.Api/appsettings.json new file mode 100644 index 0000000000..c0fa768c79 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.Api/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConfigNames": "SFA.DAS.Payments.OuterApi" +} diff --git a/src/Payments/SFA.DAS.Payments.UnitTests/Application/Learners/WhenGetLearnersQueryHandler.cs b/src/Payments/SFA.DAS.Payments.UnitTests/Application/Learners/WhenGetLearnersQueryHandler.cs new file mode 100644 index 0000000000..9d1bd3da4b --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.UnitTests/Application/Learners/WhenGetLearnersQueryHandler.cs @@ -0,0 +1,111 @@ +using AutoFixture; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using SFA.DAS.Payments.Application.Learners; +using SFA.DAS.Payments.Models.Requests; +using SFA.DAS.Payments.Models.Responses; +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.Interfaces; +using SFA.DAS.SharedOuterApi.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace SFA.DAS.Payments.UnitTests.Application.Learners +{ + [TestFixture] + public class WhenGetLearnersQueryHandler + { +#pragma warning disable CS8618 // Non-nullable field is uninitialized. - nUnit initializes fields in SetUp + private Fixture _fixture; + private Mock> _apiClientMock; + private Mock> _loggerMock; + private GetLearnersQueryHandler _handler; +#pragma warning restore CS8618 // Non-nullable field is uninitialized. + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _apiClientMock = new Mock>(); + _loggerMock = new Mock>(); + _handler = new GetLearnersQueryHandler(_loggerMock.Object, _apiClientMock.Object); + } + + [Test] + public async Task And_OnePageOfDataReturned_ThenShouldReturnLearners() + { + // Arrange + var query = _fixture.Create(); + var learners = _fixture.CreateMany(9).ToList(); + + MockResponse(learners, 1, 1); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(learners); + } + + [Test] + public async Task And_MultiplePagesOfDataReturned_ThenShouldReturnLearners() + { + // Arrange + var query = _fixture.Create(); + var learners = _fixture.CreateMany(9).ToList(); + + MockResponse(learners, 1, 3); // page 1 of 3 + MockResponse(learners, 2, 3); // page 2 of 3 + MockResponse(learners, 3, 3); // page 3 of 3 + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(learners); + } + + [Test] + public async Task And_NoDataReturned_ThenShouldReturnEmptyList() + { + // Arrange + var query = _fixture.Create(); + var emptyList = new List(); + var apiResponse = new ApiResponse>(null, System.Net.HttpStatusCode.NoContent, null, new Dictionary>()); + + _apiClientMock.Setup(x => x.GetWithResponseCode>(It.IsAny())).ReturnsAsync(apiResponse); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(emptyList); + } + + private void MockResponse(List learners, int pageNumber, int totalPages) + { + var itemsPerPage = learners.Count / totalPages; + var startIndex = (pageNumber - 1) * itemsPerPage; + + var sliceOfLearners = learners.GetRange(startIndex, itemsPerPage); + + var paginationHeaderValue = $"{{\"TotalItems\":{learners.Count},\"PageNumber\":{pageNumber},\"PageSize\":{itemsPerPage},\"TotalPages\":{totalPages}}}"; + var paginationHeader = new Dictionary> { { "X-Pagination", new[] { paginationHeaderValue } } }; + var apiResponse = new ApiResponse>(sliceOfLearners, System.Net.HttpStatusCode.OK, null, paginationHeader); + + _apiClientMock.Setup(x => x.GetWithResponseCode>(It.Is(r => RequestPageNumberIs(r,pageNumber)))).ReturnsAsync(apiResponse); + } + + private static bool RequestPageNumberIs(GetLearnersRequest request, int expectedPageNumber) + { + if(request.GetUrl.Contains($"pageNumber={expectedPageNumber}")) + { + return true; + } + return false; + } + } +} diff --git a/src/Payments/SFA.DAS.Payments.UnitTests/SFA.DAS.Payments.UnitTests.csproj b/src/Payments/SFA.DAS.Payments.UnitTests/SFA.DAS.Payments.UnitTests.csproj new file mode 100644 index 0000000000..78954fddd3 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.UnitTests/SFA.DAS.Payments.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/src/Payments/SFA.DAS.Payments.sln b/src/Payments/SFA.DAS.Payments.sln new file mode 100644 index 0000000000..0845940dd9 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments.sln @@ -0,0 +1,59 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35222.181 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments", "SFA.DAS.Payments\SFA.DAS.Payments.csproj", "{58BF78ED-6961-4BCD-9E6E-68FACFBC36C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.Api", "SFA.DAS.Payments.Api\SFA.DAS.Payments.Api.csproj", "{88FC840A-7A97-4411-8CE7-894E58DD0564}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.UnitTests", "SFA.DAS.Payments.UnitTests\SFA.DAS.Payments.UnitTests.csproj", "{720C6265-EF1B-4C49-B524-00CEFC4C5D87}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.Payments.Api.UnitTests", "SFA.DAS.Payments.Api.UnitTests\SFA.DAS.Payments.Api.UnitTests.csproj", "{90332082-C132-43AE-A664-7F65F9F0F52E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{65EE386C-94BB-4A55-BDCF-6D0573AD229F}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{268E0FE2-9159-48C2-A3BF-EDB6502E0011}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SFA.DAS.SharedOuterApi", "..\Shared\SFA.DAS.SharedOuterApi\SFA.DAS.SharedOuterApi.csproj", "{FBFA8D08-A342-4106-8805-5E4F424C3AB7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {58BF78ED-6961-4BCD-9E6E-68FACFBC36C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58BF78ED-6961-4BCD-9E6E-68FACFBC36C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58BF78ED-6961-4BCD-9E6E-68FACFBC36C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58BF78ED-6961-4BCD-9E6E-68FACFBC36C4}.Release|Any CPU.Build.0 = Release|Any CPU + {88FC840A-7A97-4411-8CE7-894E58DD0564}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88FC840A-7A97-4411-8CE7-894E58DD0564}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88FC840A-7A97-4411-8CE7-894E58DD0564}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88FC840A-7A97-4411-8CE7-894E58DD0564}.Release|Any CPU.Build.0 = Release|Any CPU + {720C6265-EF1B-4C49-B524-00CEFC4C5D87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {720C6265-EF1B-4C49-B524-00CEFC4C5D87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {720C6265-EF1B-4C49-B524-00CEFC4C5D87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {720C6265-EF1B-4C49-B524-00CEFC4C5D87}.Release|Any CPU.Build.0 = Release|Any CPU + {90332082-C132-43AE-A664-7F65F9F0F52E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90332082-C132-43AE-A664-7F65F9F0F52E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90332082-C132-43AE-A664-7F65F9F0F52E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90332082-C132-43AE-A664-7F65F9F0F52E}.Release|Any CPU.Build.0 = Release|Any CPU + {FBFA8D08-A342-4106-8805-5E4F424C3AB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBFA8D08-A342-4106-8805-5E4F424C3AB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBFA8D08-A342-4106-8805-5E4F424C3AB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBFA8D08-A342-4106-8805-5E4F424C3AB7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FBFA8D08-A342-4106-8805-5E4F424C3AB7} = {268E0FE2-9159-48C2-A3BF-EDB6502E0011} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1FBA9471-0084-450A-961B-4B248AA9922B} + EndGlobalSection +EndGlobal diff --git a/src/Payments/SFA.DAS.Payments/Application/Learners/GetLearnersQueryHandler.cs b/src/Payments/SFA.DAS.Payments/Application/Learners/GetLearnersQueryHandler.cs new file mode 100644 index 0000000000..0bd1dc0c53 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments/Application/Learners/GetLearnersQueryHandler.cs @@ -0,0 +1,104 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using SFA.DAS.Payments.Models.Requests; +using SFA.DAS.Payments.Models.Responses; +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.Interfaces; +using SFA.DAS.SharedOuterApi.Models; + +namespace SFA.DAS.Payments.Application.Learners +{ + public class GetLearnersQuery : IRequest> + { + public string Ukprn { get; set; } + public short AcademicYear { get; set; } + public GetLearnersQuery(string ukprn, short academicYear) + { + Ukprn = ukprn; + AcademicYear = academicYear; + } + } + + public class GetLearnersQueryHandler : IRequestHandler> + { + private readonly ILearnerDataApiClient _apiClient; + private readonly ILogger _logger; + + public GetLearnersQueryHandler( + ILogger logger, ILearnerDataApiClient apiClient) + { + _apiClient = apiClient; + _logger = logger; + } + + public async Task> Handle(GetLearnersQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting learners for UKPRN: {ukprn} and Academic Year: {academicYear}", request.Ukprn, request.AcademicYear); + List learners = new List(); + + var initialResponse = await _apiClient.GetWithResponseCode>(new GetLearnersRequest(request.Ukprn, request.AcademicYear, 1)); + if(initialResponse.StatusCode == System.Net.HttpStatusCode.NoContent) + { + _logger.LogWarning("No learners found for UKPRN: {ukprn} and Academic Year: {academicYear}", request.Ukprn, request.AcademicYear); + return learners; + } + + learners.AddRange(initialResponse.Body); + + var paginationHeader = GetPaginationHeader(initialResponse); + + for (var i = 1; i < paginationHeader.TotalPages; i++) // Start at 1 because we've already got the first page + { + var pageNumber = i + 1; // The API is 1-based + var additionalPage = await _apiClient.GetWithResponseCode>(new GetLearnersRequest(request.Ukprn, request.AcademicYear, pageNumber)); + learners.AddRange(additionalPage.Body); + } + + _logger.LogInformation("Retrieved {learnerCount} learners", learners.Count); + return learners; + } + + private PaginationHeader GetPaginationHeader(ApiResponse> apiResponse) + { + PaginationHeader? paginationHeader; + + try + { + if(!apiResponse.Headers.ContainsKey("X-Pagination")) + { + _logger.LogWarning("No X-Pagination header returned from LearnerData endpoint. This is acceptable when working with Stub only"); + return new PaginationHeader + { + TotalItems = apiResponse.Body.Count, + PageNumber = 1, + PageSize = apiResponse.Body.Count, + TotalPages = 1 + }; + } + + var paginationHeaderString = apiResponse.Headers["X-Pagination"].Single(); + paginationHeader = Newtonsoft.Json.JsonConvert.DeserializeObject(paginationHeaderString); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to parse pagination header"); + throw; + } + + if (paginationHeader == null) + { + throw new Exception("Failed to parse pagination header"); + } + + return paginationHeader; + } + + private class PaginationHeader + { + public int TotalItems { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalPages { get; set; } + } + } +} diff --git a/src/Payments/SFA.DAS.Payments/Models/Requests/GetLearnersRequest.cs b/src/Payments/SFA.DAS.Payments/Models/Requests/GetLearnersRequest.cs new file mode 100644 index 0000000000..f743689e58 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments/Models/Requests/GetLearnersRequest.cs @@ -0,0 +1,20 @@ +using SFA.DAS.SharedOuterApi.Interfaces; + +namespace SFA.DAS.Payments.Models.Requests +{ + public class GetLearnersRequest : IGetApiRequest + { + private readonly string _ukprn; + private readonly short _academicYear; + private readonly int _pageNumber; + + public string GetUrl => $"learners/{_academicYear}?ukprn={_ukprn}&pageNumber={_pageNumber}&pageSize=1000"; + + public GetLearnersRequest(string ukprn, short academicYear, int pageNumber) + { + _ukprn = ukprn; + _academicYear = academicYear; + _pageNumber = pageNumber; + } + } +} diff --git a/src/Payments/SFA.DAS.Payments/Models/Responses/LearnerResponse.cs b/src/Payments/SFA.DAS.Payments/Models/Responses/LearnerResponse.cs new file mode 100644 index 0000000000..f3bd38f26d --- /dev/null +++ b/src/Payments/SFA.DAS.Payments/Models/Responses/LearnerResponse.cs @@ -0,0 +1,33 @@ +namespace SFA.DAS.Payments.Models.Responses +{ + public class LearnerResponse + { + public int Ukprn { get; set; } + public string LearnRefNumber { get; set; } + public long Uln { get; set; } + public string FamilyName { get; set; } + public string GivenNames { get; set; } + public DateTime DateOfBirth { get; set; } + public string NiNumber { get; set; } + public Learningdelivery[] LearningDeliveries { get; set; } + } + + public class Learningdelivery + { + public int AimType { get; set; } + public DateTime LearnStartDate { get; set; } + public DateTime LearnPlanEndDate { get; set; } + public int FundModel { get; set; } + public int StdCode { get; set; } + public string DelLocPostCode { get; set; } + public string EpaOrgID { get; set; } + public int CompStatus { get; set; } + public DateTime LearnActEndDate { get; set; } + public int WithdrawReason { get; set; } + public int Outcome { get; set; } + public DateTime AchDate { get; set; } + public string OutGrade { get; set; } + public int ProgType { get; set; } + } + +} diff --git a/src/Payments/SFA.DAS.Payments/SFA.DAS.Payments.csproj b/src/Payments/SFA.DAS.Payments/SFA.DAS.Payments.csproj new file mode 100644 index 0000000000..5441276dc8 --- /dev/null +++ b/src/Payments/SFA.DAS.Payments/SFA.DAS.Payments.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/Payments/azure-pipelines.yml b/src/Payments/azure-pipelines.yml new file mode 100644 index 0000000000..7e7d3e6f51 --- /dev/null +++ b/src/Payments/azure-pipelines.yml @@ -0,0 +1,53 @@ +trigger: + batch: true + branches: + include: + - "master" + paths: + include: + - src/Payments + exclude: + - azure + - pipeline-templates + - deployments + +pr: + autoCancel: true + branches: + include: + - "master" + paths: + include: + - src/Payments + exclude: + - azure + - pipeline-templates + - deployments + +variables: +- group: Release Management Resources +- group: RELEASE das-apim-endpoints + +resources: + repositories: + - repository: self + - repository: das-platform-building-blocks + type: github + name: SkillsFundingAgency/das-platform-building-blocks + ref: refs/tags/2.2.0 + endpoint: SkillsFundingAgency + - repository: das-platform-automation + type: github + name: SkillsFundingAgency/das-platform-automation + ref: refs/tags/5.1.10 + endpoint: SkillsFundingAgency + pipelines: + - pipeline: das-employer-config + project: Digital Apprenticeship Service + source: das-employer-config + branch: master + +stages: +- template: ../../pipeline-templates/stage/outerapi-pipeline.yml + parameters: + OuterApiName: Payments diff --git a/src/Payments/pipeline-templates-payments/job/deploy.yml b/src/Payments/pipeline-templates-payments/job/deploy.yml new file mode 100644 index 0000000000..e79f582224 --- /dev/null +++ b/src/Payments/pipeline-templates-payments/job/deploy.yml @@ -0,0 +1,162 @@ +parameters: + ServiceConnection: + AppRoleAssignmentsServiceConnection: + Environment: + AADGroupObjectIdArray: + SchemaFilePath: + ConfigurationSecrets: + DeploymentName: + AppServiceName: + CustomHostName: + AppServiceKeyVaultCertificateName: + ConfigNames: + DeploymentPackagePath: + ApiVersionSetName: + ApiPath: + ApiBaseUrl: + ProductId: + ApplicationIdentifierUri: + SandboxEnabled: $False + AddXForwardedAuthorization: $False + +jobs: +- deployment: DeployInfrastructureNew + condition: eq( variables.platformBuildBlockVersion, 'refs/tags/2.2.0') + pool: + name: DAS - Continuous Deployment Agents + environment: ${{ parameters.Environment }} + workspace: + clean: all + variables: + AppServiceName: ${{ parameters.AppServiceName }} + CustomHostName: ${{ parameters.CustomHostName }} + AppServiceKeyVaultCertificateName: ${{ parameters.AppServiceKeyVaultCertificateName }} + ConfigNames: ${{ parameters.ConfigNames }} + strategy: + runOnce: + deploy: + steps: + - template: azure-pipelines-templates/deploy/step/wait-azure-devops-deployment.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.ServiceConnection }} + EnvironmentId: $(Environment.Id) + PipelineName: $(Build.DefinitionName) + RunId: $(Build.BuildId) + - template: azure-pipelines-templates/deploy/step/arm-deploy.yml@das-platform-building-blocks + parameters: + Location: $(ResourceGroupLocation) + ServiceConnection: ${{ parameters.ServiceConnection }} + SubscriptionId: $(SubscriptionId) + TemplatePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/azure/template.json + ParametersPath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/azure/template.parameters.json + IsMultiRepoCheckout: true + TemplateSecrets: + LoggingRedisConnectionString: $(LoggingRedisConnectionString) + ConfigurationStorageConnectionString: $(ConfigurationStorageConnectionString) + - template: azure-pipelines-templates/deploy/step/generate-config.yml@das-platform-building-blocks + parameters: + EnvironmentName: $(EnvironmentName) + ServiceConnection: ${{ parameters.ServiceConnection }} + SourcePath: $(Pipeline.Workspace)/das-employer-config/Configuration/das-apim-endpoints + StorageAccountName: $(ConfigurationStorageAccountName) + StorageAccountResourceGroup: $(SharedEnvResourceGroup) + TargetFileName: ${{ parameters.SchemaFilePath }} + TableName: Configuration + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + - template: azure-pipelines-templates/deploy/step/app-deploy.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.ServiceConnection }} + AppServiceName: ${{ parameters.AppServiceName }} + DeploymentPackagePath: ${{ parameters.DeploymentPackagePath }} + - checkout: das-platform-automation + - task: AzurePowerShell@5 + displayName: Import-ApimSwaggerApiDefinition - ${{ parameters.AppServiceName }} + inputs: + azureSubscription: ${{ parameters.ServiceConnection }} + ScriptPath: das-platform-automation/Infrastructure-Scripts/Import-ApimSwaggerApiDefinition.ps1 + ScriptArguments: + -ApimResourceGroup $(ApimResourceGroup) ` + -InstanceName $(InstanceName) ` + -AppServiceResourceGroup $(ResourceGroupName) ` + -ApiVersionSetName ${{ parameters.ApiVersionSetName }} ` + -ApiPath ${{ parameters.ApiPath }} ` + -ApiBaseUrl ${{ parameters.ApiBaseUrl }} ` + -ProductId ${{ parameters.ProductId }} ` + -ApplicationIdentifierUri ${{ parameters.ApplicationIdentifierUri }} ` + -SandboxEnabled ${{ parameters.SandboxEnabled }} ` + -AddXForwardedAuthorization ${{ parameters.AddXForwardedAuthorization }} ` + -Verbose + azurePowerShellVersion: LatestVersion + pwsh: true + +- deployment: DeployInfrastructureOld + condition: ne( variables.platformBuildBlockVersion, 'refs/tags/2.2.0') + pool: + name: DAS - Continuous Deployment Agents + environment: ${{ parameters.Environment }} + workspace: + clean: all + variables: + AppServiceName: ${{ parameters.AppServiceName }} + CustomHostName: ${{ parameters.CustomHostName }} + AppServiceKeyVaultCertificateName: ${{ parameters.AppServiceKeyVaultCertificateName }} + ConfigNames: ${{ parameters.ConfigNames }} + strategy: + runOnce: + deploy: + steps: + - template: azure-pipelines-templates/deploy/step/wait-azure-devops-deployment.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.ServiceConnection }} + EnvironmentId: $(Environment.Id) + PipelineName: $(Build.DefinitionName) + RunId: $(Build.BuildId) + - template: azure-pipelines-templates/deploy/step/arm-deploy.yml@das-platform-building-blocks + parameters: + Location: $(ResourceGroupLocation) + ServiceConnection: ${{ parameters.ServiceConnection }} + SubscriptionId: $(SubscriptionId) + TemplatePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/azure/template.json + ParametersPath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/azure/template.parameters.json + IsMultiRepoCheckout: true + TemplateSecrets: + LoggingRedisConnectionString: $(LoggingRedisConnectionString) + ConfigurationStorageConnectionString: $(ConfigurationStorageConnectionString) + - template: azure-pipelines-templates/deploy/step/generate-config.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.ServiceConnection }} + SourcePath: $(Pipeline.Workspace)/das-employer-config/Configuration/das-apim-endpoints + TargetFileName: ${{ parameters.SchemaFilePath }} + TableName: Configuration + - template: azure-pipelines-templates/deploy/step/app-role-assignments.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.AppRoleAssignmentsServiceConnection }} + ResourceName: ${{ parameters.AppServiceName }} + Tenant: $(Tenant) + AADGroupObjectIdArray: ${{ parameters.AADGroupObjectIdArray }} + IsMultiRepoCheckout: true + - template: azure-pipelines-templates/deploy/step/app-deploy.yml@das-platform-building-blocks + parameters: + ServiceConnection: ${{ parameters.ServiceConnection }} + AppServiceName: ${{ parameters.AppServiceName }} + DeploymentPackagePath: ${{ parameters.DeploymentPackagePath }} + - checkout: das-platform-automation + - task: AzurePowerShell@5 + displayName: Import-ApimSwaggerApiDefinition - ${{ parameters.AppServiceName }} + inputs: + azureSubscription: ${{ parameters.ServiceConnection }} + ScriptPath: das-platform-automation/Infrastructure-Scripts/Import-ApimSwaggerApiDefinition.ps1 + ScriptArguments: + -ApimResourceGroup $(ApimResourceGroup) ` + -InstanceName $(InstanceName) ` + -AppServiceResourceGroup $(ResourceGroupName) ` + -ApiVersionSetName ${{ parameters.ApiVersionSetName }} ` + -ApiPath ${{ parameters.ApiPath }} ` + -ApiBaseUrl ${{ parameters.ApiBaseUrl }} ` + -ProductId ${{ parameters.ProductId }} ` + -ApplicationIdentifierUri ${{ parameters.ApplicationIdentifierUri }} ` + -SandboxEnabled ${{ parameters.SandboxEnabled }} ` + -AddXForwardedAuthorization ${{ parameters.AddXForwardedAuthorization }} ` + -Verbose + azurePowerShellVersion: LatestVersion + pwsh: true diff --git a/src/Payments/pipeline-templates-payments/stage/outerapi-pipeline.yml b/src/Payments/pipeline-templates-payments/stage/outerapi-pipeline.yml new file mode 100644 index 0000000000..3a1add0ecd --- /dev/null +++ b/src/Payments/pipeline-templates-payments/stage/outerapi-pipeline.yml @@ -0,0 +1,250 @@ +parameters: + OuterApiName: + SandboxEnabled: $False + AddXForwardedAuthorization: $False + SharedOuterApiProjectPathToInclude: 'src/Shared/SFA.DAS.SharedOuterApi/SFA.DAS.SharedOuterApi.csproj' + SharedOuterApiTestProjectPathToInclude: 'src/Shared/SFA.DAS.SharedOuterApi.UnitTests/SFA.DAS.SharedOuterApi.UnitTests.csproj' + AdditionalProjectPathToInclude: '' + AdditionalTestProjectPathToInclude: '' + ConfigurationSecrets: {} + +stages: +- stage: Build + jobs: + - template: ../../../../pipeline-templates/job/code-build.yml + parameters: + SonarCloudProjectKey: SkillsFundingAgency_das-apim-endpoints_${{ parameters.OuterApiName }} + TargetProjects: | + src/${{ parameters.OuterApiName }}/**/*.csproj + ${{ parameters.SharedOuterApiProjectPathToInclude }} + ${{ parameters.SharedOuterApiTestProjectPathToInclude }} + ${{ parameters.AdditionalProjectPathToInclude }} + ${{ parameters.AdditionalTestProjectPathToInclude }} + UnitTestProjects: | + src/${{ parameters.OuterApiName }}/**/*UnitTests.csproj + ${{ parameters.SharedOuterApiTestProjectPathToInclude }} + ${{ parameters.AdditionalTestProjectPathToInclude }} + AcceptanceTestProjects: 'src/${{ parameters.OuterApiName }}/**/*AcceptanceTests.csproj' + PublishProject: 'src/${{ parameters.OuterApiName }}/SFA.DAS.${{ parameters.OuterApiName }}.Api/SFA.DAS.${{ parameters.OuterApiName }}.Api.csproj' + +- stage: Deploy_AT + dependsOn: Build + displayName: Deploy to AT + variables: + - group: DevTest Management Resources + - group: AT DevTest Shared Resources + - group: AT das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DAS-DevTest-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-CDS + Environment: AT + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AADGroupObjectIdArray: $(AdminAADGroupObjectId),$(DevAADGroupObjectId) + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_TEST + dependsOn: Build + displayName: Deploy to TEST + variables: + - group: DevTest Management Resources + - group: TEST DevTest Shared Resources + - group: TEST das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DAS-DevTest-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-CDS + Environment: TEST + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AADGroupObjectIdArray: $(AdminAADGroupObjectId),$(DevAADGroupObjectId) + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_TEST2 + dependsOn: Build + displayName: Deploy to TEST2 + variables: + - group: DevTest Management Resources + - group: TEST2 DevTest Shared Resources + - group: TEST2 das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DAS-DevTest-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-CDS + Environment: TEST2 + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AADGroupObjectIdArray: $(AdminAADGroupObjectId),$(DevAADGroupObjectId) + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_PP + dependsOn: Build + displayName: Deploy to PP + variables: + - group: PREPROD Management Resources + - group: PREPROD Shared Resources + - group: PREPROD das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DIG-PreProd-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-FCS + Environment: PP + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AADGroupObjectIdArray: $(AdminAADGroupObjectId),$(DevAADGroupObjectId) + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_PROD + dependsOn: Build + displayName: Deploy to PROD + variables: + - group: PROD Management Resources + - group: PROD Shared Resources + - group: PROD das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DIG-Prod-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-FCS + Environment: PROD + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_MO + dependsOn: Build + displayName: Deploy to MO + variables: + - group: MO Management Resources + - group: MO Shared Resources + - group: MO das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-ASM-ModelOffice-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-FCS + Environment: MO + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} + +- stage: Deploy_DEMO + dependsOn: Build + displayName: Deploy to DEMO + variables: + - group: DevTest Management Resources + - group: DEMO DevTest Shared Resources + - group: DEMO das-apim-endpoints + - name: platformBuildBlockVersion + value: $[ resources.repositories['das-platform-building-blocks'].ref ] + jobs: + - template: ../../../../src/Payments/pipeline-templates-payments/job/deploy.yml + parameters: + ServiceConnection: SFA-DAS-DevTest-ARM + AppRoleAssignmentsServiceConnection: das-app-role-assignments-CDS + Environment: DEMO + SchemaFilePath: $(${{ parameters.OuterApiName }}OuterApiSchemaFilePath) + ConfigurationSecrets: ${{ parameters.ConfigurationSecrets }} + DeploymentName: Deploy_${{ parameters.OuterApiName }} + AADGroupObjectIdArray: $(AdminAADGroupObjectId),$(DevAADGroupObjectId) + AppServiceName: $(${{ parameters.OuterApiName }}OuterApiAppServiceName) + CustomHostName: $(${{ parameters.OuterApiName }}OuterApiCustomHostname) + AppServiceKeyVaultCertificateName: $(${{ parameters.OuterApiName }}OuterApiKeyVaultCertificateName) + ConfigNames: $(${{ parameters.OuterApiName }}OuterApiConfigNames) + DeploymentPackagePath: $(Pipeline.Workspace)/ApimEndpointsArtifacts/SFA.DAS.${{ parameters.OuterApiName }}.Api.zip + ApiVersionSetName: $(${{ parameters.OuterApiName }}OuterApiVersionSetName) + ApiPath: $(${{ parameters.OuterApiName }}OuterApiPath) + ApiBaseUrl: $(${{ parameters.OuterApiName }}OuterApiBaseUrl) + ProductId: $(${{ parameters.OuterApiName }}ProductId) + ApplicationIdentifierUri: $(${{ parameters.OuterApiName }}OuterApiIdentifierUri) + SandboxEnabled: ${{ parameters.SandboxEnabled }} + AddXForwardedAuthorization: ${{ parameters.AddXForwardedAuthorization }} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Configuration/AccessTokenProviderApiConfiguration.cs b/src/Shared/SFA.DAS.SharedOuterApi/Configuration/AccessTokenProviderApiConfiguration.cs new file mode 100644 index 0000000000..aeff43a704 --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Configuration/AccessTokenProviderApiConfiguration.cs @@ -0,0 +1,13 @@ +namespace SFA.DAS.SharedOuterApi.Configuration +{ + public class AccessTokenProviderApiConfiguration + { + public string Url { get; set; } + public string Scope { get; set; } + public string ClientId { get; set; } + public string Tenant { get; set; } + public string ClientSecret { get; set; } + public string GrantType { get; set; } + public bool ShouldSkipForLocal { get; set; } + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Configuration/LearnerDataApiConfiguration.cs b/src/Shared/SFA.DAS.SharedOuterApi/Configuration/LearnerDataApiConfiguration.cs new file mode 100644 index 0000000000..e70ab41a48 --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Configuration/LearnerDataApiConfiguration.cs @@ -0,0 +1,10 @@ +using SFA.DAS.SharedOuterApi.Interfaces; + +namespace SFA.DAS.SharedOuterApi.Configuration +{ + public class LearnerDataApiConfiguration : IAccessTokenApiConfiguration + { + public string Url { get; set; } + public AccessTokenProviderApiConfiguration TokenSettings { get; set; } + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/AccessTokenApiClient.cs b/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/AccessTokenApiClient.cs new file mode 100644 index 0000000000..8a8185600c --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/AccessTokenApiClient.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; +using SFA.DAS.SharedOuterApi.Interfaces; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace SFA.DAS.SharedOuterApi.Infrastructure +{ + public class AccessTokenApiClient : ApiClient, IAccessTokenApiClient where T : IAccessTokenApiConfiguration + { + private readonly HttpClient _tokenClient; + private readonly ILogger> _logger; + + public AccessTokenApiClient( + ILogger> logger, + IHttpClientFactory httpClientFactory, + T apiConfiguration) : base(httpClientFactory, apiConfiguration) + { + _logger = logger; + _tokenClient = httpClientFactory.CreateClient(); + _tokenClient.BaseAddress = new Uri(apiConfiguration.TokenSettings.Url); + } + + protected override async Task AddAuthenticationHeader(HttpRequestMessage httpRequestMessage) + { + if (Configuration.TokenSettings.ShouldSkipForLocal) + { + _logger.LogWarning("Token acquisition is skipped. This should not happen in a production environment."); + return; + } + + var token = string.Empty; + + try + { + token = await GetAccessToken(); + } + catch (Exception e) + { + throw new UnauthorizedAccessException("Could not retrieve access token", e); + } + + httpRequestMessage.Headers.Add("Authorization", $"Bearer {token}"); + } + + private async Task GetAccessToken() + { + var tokenMessage = new HttpRequestMessage(HttpMethod.Get, Configuration.TokenSettings.Tenant); + tokenMessage.Content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", Configuration.TokenSettings.ClientId), + new KeyValuePair("scope", Configuration.TokenSettings.Scope), + new KeyValuePair("client_secret", Configuration.TokenSettings.ClientSecret), + new KeyValuePair("grant_type", "client_credentials") + }); + + var response = await _tokenClient.SendAsync(tokenMessage).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Could not retrieve access token. Status code: {response.StatusCode}, Response: {json}"); + } + + var tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + return tokenResponse.access_token; + } + + private class TokenResponse + { + public string access_token { get; set; } + } + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/GetApiClient.cs b/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/GetApiClient.cs index 256f85acea..4a17724173 100644 --- a/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/GetApiClient.cs +++ b/src/Shared/SFA.DAS.SharedOuterApi/Infrastructure/GetApiClient.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using SFA.DAS.SharedOuterApi.Models; using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Linq; namespace SFA.DAS.SharedOuterApi.Infrastructure { @@ -77,7 +79,9 @@ public async Task> GetWithResponseCode(IGetApi responseBody = JsonSerializer.Deserialize(json, options); } - var getWithResponseCode = new ApiResponse(responseBody, response.StatusCode, errorContent); + var headers = response.Headers; + + var getWithResponseCode = new ApiResponse(responseBody, response.StatusCode, errorContent, GetHeaders(response)); return getWithResponseCode; } @@ -89,5 +93,14 @@ private static bool IsNot200RangeResponseCode(HttpStatusCode statusCode) protected abstract Task AddAuthenticationHeader(HttpRequestMessage httpRequestMessage); + private static Dictionary> GetHeaders(HttpResponseMessage httpResponseMessage) + { + if(httpResponseMessage?.Headers == null && !httpResponseMessage.Headers.Any()) + { + return new Dictionary>(); + } + + return httpResponseMessage.Headers.ToDictionary(h => h.Key, h => h.Value); + } } } diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiClient.cs b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiClient.cs new file mode 100644 index 0000000000..f0eed6aaeb --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiClient.cs @@ -0,0 +1,6 @@ +namespace SFA.DAS.SharedOuterApi.Interfaces +{ + public interface IAccessTokenApiClient : IApiClient + { + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiConfiguration.cs b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiConfiguration.cs new file mode 100644 index 0000000000..7383204313 --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/IAccessTokenApiConfiguration.cs @@ -0,0 +1,9 @@ +using SFA.DAS.SharedOuterApi.Configuration; + +namespace SFA.DAS.SharedOuterApi.Interfaces +{ + public interface IAccessTokenApiConfiguration : IApiConfiguration + { + AccessTokenProviderApiConfiguration TokenSettings { get; set; } + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/ILearnerDataApiClient.cs b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/ILearnerDataApiClient.cs new file mode 100644 index 0000000000..7cab632fb4 --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Interfaces/ILearnerDataApiClient.cs @@ -0,0 +1,6 @@ +namespace SFA.DAS.SharedOuterApi.Interfaces +{ + public interface ILearnerDataApiClient : IGetApiClient + { + } +} diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Models/ApiResponse.cs b/src/Shared/SFA.DAS.SharedOuterApi/Models/ApiResponse.cs index 3aeb6e1506..a91b0955fc 100644 --- a/src/Shared/SFA.DAS.SharedOuterApi/Models/ApiResponse.cs +++ b/src/Shared/SFA.DAS.SharedOuterApi/Models/ApiResponse.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Collections.Generic; +using System.Net; namespace SFA.DAS.SharedOuterApi.Models { @@ -7,12 +8,19 @@ public class ApiResponse public TResponse Body { get; } public HttpStatusCode StatusCode { get; } public string ErrorContent { get ; } + public Dictionary> Headers { get; } - public ApiResponse (TResponse body, HttpStatusCode statusCode, string errorContent) + public ApiResponse(TResponse body, HttpStatusCode statusCode, string errorContent) :this(body, statusCode, errorContent, new Dictionary>()) + { + + } + + public ApiResponse (TResponse body, HttpStatusCode statusCode, string errorContent, Dictionary> headers) { Body = body; StatusCode = statusCode; ErrorContent = errorContent; + Headers = headers; } } } \ No newline at end of file diff --git a/src/Shared/SFA.DAS.SharedOuterApi/Services/LearnerDataApiClient.cs b/src/Shared/SFA.DAS.SharedOuterApi/Services/LearnerDataApiClient.cs new file mode 100644 index 0000000000..c97728fd31 --- /dev/null +++ b/src/Shared/SFA.DAS.SharedOuterApi/Services/LearnerDataApiClient.cs @@ -0,0 +1,33 @@ +using SFA.DAS.SharedOuterApi.Configuration; +using SFA.DAS.SharedOuterApi.Interfaces; +using SFA.DAS.SharedOuterApi.Models; +using System.Net; +using System.Threading.Tasks; + +namespace SFA.DAS.SharedOuterApi.Services +{ + public class LearnerDataApiClient : ILearnerDataApiClient + { + private readonly IAccessTokenApiClient _apiClient; + + public LearnerDataApiClient(IAccessTokenApiClient apiClient) + { + _apiClient = apiClient; + } + + public async Task Get(IGetApiRequest request) + { + return await _apiClient.Get(request); + } + + public async Task> GetWithResponseCode(IGetApiRequest request) + { + return await _apiClient.GetWithResponseCode(request); + } + + public async Task GetResponseCode(IGetApiRequest request) + { + return await _apiClient.GetResponseCode(request); + } + } +}