diff --git a/.github/workflows/build-and-push-package.yml b/.github/workflows/build-and-push-package.yml index 97ab61b19..6cef3b1c2 100644 --- a/.github/workflows/build-and-push-package.yml +++ b/.github/workflows/build-and-push-package.yml @@ -3,7 +3,7 @@ name: Build and Push NuGet Package on: push: branches: - - package/release.* + - release/* paths: - 'Dfe.PersonsApi.Client*/**' diff --git a/Dfe.Academies.Api.Infrastructure/Caching/CacheSettings.cs b/Dfe.Academies.Api.Infrastructure/Caching/CacheSettings.cs new file mode 100644 index 000000000..edb7602b1 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Caching/CacheSettings.cs @@ -0,0 +1,8 @@ +namespace Dfe.Academies.Infrastructure.Caching +{ + public class CacheSettings + { + public int DefaultDurationInSeconds { get; set; } = 5; + public Dictionary Durations { get; set; } = new(); + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Caching/MemoryCacheService.cs b/Dfe.Academies.Api.Infrastructure/Caching/MemoryCacheService.cs new file mode 100644 index 000000000..a988af425 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Caching/MemoryCacheService.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Diagnostics.CodeAnalysis; +using Dfe.Academies.Domain.Interfaces.Caching; + +namespace Dfe.Academies.Infrastructure.Caching +{ + [ExcludeFromCodeCoverage] + public class MemoryCacheService( + IMemoryCache memoryCache, + ILogger logger, + IOptions cacheSettings) + : ICacheService + { + private readonly CacheSettings _cacheSettings = cacheSettings.Value; + + public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName) + { + if (memoryCache.TryGetValue(cacheKey, out T? cachedValue)) + { + logger.LogInformation("Cache hit for key: {CacheKey}", cacheKey); + return cachedValue!; + } + + logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from source...", cacheKey); + var result = await fetchFunction(); + + if (Equals(result, default(T))) return result; + var cacheDuration = GetCacheDurationForMethod(methodName); + memoryCache.Set(cacheKey, result, cacheDuration); + logger.LogInformation("Cached result for key: {CacheKey} for duration: {CacheDuration}", cacheKey, cacheDuration); + + return result; + } + + public void Remove(string cacheKey) + { + memoryCache.Remove(cacheKey); + logger.LogInformation("Cache removed for key: {CacheKey}", cacheKey); + } + + private TimeSpan GetCacheDurationForMethod(string methodName) + { + if (_cacheSettings.Durations.TryGetValue(methodName, out int durationInSeconds)) + { + return TimeSpan.FromSeconds(durationInSeconds); + } + return TimeSpan.FromSeconds(_cacheSettings.DefaultDurationInSeconds); + } + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj b/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj index 92324924f..0238d0b2b 100644 --- a/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj +++ b/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj @@ -9,20 +9,25 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + - + - + + diff --git a/Dfe.Academies.Api.Infrastructure/EdperfContext.cs b/Dfe.Academies.Api.Infrastructure/EdperfContext.cs index 1f9dc5dd8..0a0c016c0 100644 --- a/Dfe.Academies.Api.Infrastructure/EdperfContext.cs +++ b/Dfe.Academies.Api.Infrastructure/EdperfContext.cs @@ -1,14 +1,6 @@ -using Dfe.Academies.Academisation.Data; -using Dfe.Academies.Domain.EducationalPerformance; -using Dfe.Academies.Domain.Establishment; -using Dfe.Academies.Domain.Trust; +using Dfe.Academies.Domain.EducationalPerformance; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Dfe.Academies.Infrastructure { diff --git a/Dfe.Academies.Api.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/Dfe.Academies.Api.Infrastructure/InfrastructureServiceCollectionExtensions.cs new file mode 100644 index 000000000..59d9b79d4 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +using Dfe.Academies.Application.Common.Interfaces; +using Dfe.Academies.Infrastructure; +using Dfe.Academies.Infrastructure.Caching; +using Dfe.Academies.Infrastructure.Repositories; +using Dfe.Academies.Infrastructure.Security.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Infrastructure.QueryServices; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class InfrastructureServiceCollectionExtensions + { + public static IServiceCollection AddInfrastructureDependencyGroup( + this IServiceCollection services, IConfiguration config) + { + //Repos + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + + //Db + var connectionString = config.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + + return services; + } + + public static IServiceCollection AddPersonsApiInfrastructureDependencyGroup( + this IServiceCollection services, IConfiguration config) + { + //Repos + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(typeof(IMstrRepository<>), typeof(MstrRepository<>)); + services.AddScoped(typeof(IMopRepository<>), typeof(MopRepository<>)); + + // Query Services + services.AddScoped(); + + //Cache service + services.Configure(config.GetSection("CacheSettings")); + services.AddSingleton(); + + //Db + var connectionString = config.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + + // Authentication + services.AddCustomAuthorization(config); + + return services; + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/20240115130158_Initial.Designer.cs b/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/20240115130158_Initial.Designer.cs index 9a50e4e26..8e8595df0 100644 --- a/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/20240115130158_Initial.Designer.cs +++ b/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/20240115130158_Initial.Designer.cs @@ -1,6 +1,5 @@ // using System; -using Dfe.Academies.Academisation.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; diff --git a/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/MstrContextModelSnapshot.cs b/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/MstrContextModelSnapshot.cs index 07d70bea3..7c26d1f27 100644 --- a/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/MstrContextModelSnapshot.cs +++ b/Dfe.Academies.Api.Infrastructure/Migrations/Mstr/MstrContextModelSnapshot.cs @@ -1,6 +1,5 @@ // using System; -using Dfe.Academies.Academisation.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -20,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "6.0.25") .HasAnnotation("Relational:MaxIdentifierLength", 128); - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); modelBuilder.Entity("Dfe.Academies.Domain.Establishment.EducationEstablishmentTrust", b => { @@ -28,7 +27,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("EducationEstablishmentId") .HasColumnType("int") @@ -49,7 +48,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("AddressLine1") .HasColumnType("nvarchar(max)") @@ -413,7 +412,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("Code") .HasColumnType("nvarchar(max)"); @@ -446,7 +445,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("DeliveryProcessPAN") .HasColumnType("nvarchar(max)") @@ -479,7 +478,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("Code") .HasColumnType("nvarchar(max)"); @@ -518,7 +517,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("AMSDTerritory") .HasColumnType("nvarchar(max)") @@ -699,7 +698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("bigint"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK"), 1L, 1); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SK")); b.Property("Code") .HasColumnType("nvarchar(max)"); diff --git a/Dfe.Academies.Api.Infrastructure/MopContext.cs b/Dfe.Academies.Api.Infrastructure/MopContext.cs index 78a4a4c80..097341527 100644 --- a/Dfe.Academies.Api.Infrastructure/MopContext.cs +++ b/Dfe.Academies.Api.Infrastructure/MopContext.cs @@ -1,8 +1,9 @@ -using Dfe.Academies.Domain.Persons; +using Dfe.Academies.Domain.Constituencies; +using Dfe.Academies.Domain.ValueObjects; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Dfe.Academies.Academisation.Data; +namespace Dfe.Academies.Infrastructure; public class MopContext : DbContext { @@ -37,27 +38,47 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); } - - private void ConfigureMemberContactDetails(EntityTypeBuilder memberContactDetailsConfiguration) + private static void ConfigureMemberContactDetails(EntityTypeBuilder memberContactDetailsConfiguration) { - memberContactDetailsConfiguration.HasKey(e => e.MemberID); + memberContactDetailsConfiguration.HasKey(e => e.MemberId); memberContactDetailsConfiguration.ToTable("MemberContactDetails", DEFAULT_SCHEMA); - memberContactDetailsConfiguration.Property(e => e.MemberID).HasColumnName("memberID"); + memberContactDetailsConfiguration.Property(e => e.MemberId).HasColumnName("memberID") + .HasConversion( + v => v.Value, + v => new MemberId(v)); memberContactDetailsConfiguration.Property(e => e.Email).HasColumnName("email"); + memberContactDetailsConfiguration.Property(e => e.Phone).HasColumnName("phone"); memberContactDetailsConfiguration.Property(e => e.TypeId).HasColumnName("typeId"); } private void ConfigureConstituency(EntityTypeBuilder constituencyConfiguration) { constituencyConfiguration.ToTable("Constituencies", DEFAULT_SCHEMA); - constituencyConfiguration.Property(e => e.ConstituencyId).HasColumnName("constituencyId"); + constituencyConfiguration.Property(e => e.ConstituencyId).HasColumnName("constituencyId") + .HasConversion( + v => v.Value, + v => new ConstituencyId(v)); + constituencyConfiguration.Property(e => e.MemberId) + .HasConversion( + v => v.Value, + v => new MemberId(v)); constituencyConfiguration.Property(e => e.ConstituencyName).HasColumnName("constituencyName"); - constituencyConfiguration.Property(e => e.NameList).HasColumnName("nameListAs"); - constituencyConfiguration.Property(e => e.NameDisplayAs).HasColumnName("nameDisplayAs"); - constituencyConfiguration.Property(e => e.NameFullTitle).HasColumnName("nameFullTitle"); - constituencyConfiguration.Property(e => e.NameFullTitle).HasColumnName("nameFullTitle"); + + constituencyConfiguration.OwnsOne(e => e.NameDetails, nameDetails => + { + nameDetails.Property(nd => nd.NameListAs).HasColumnName("nameListAs"); + nameDetails.Property(nd => nd.NameDisplayAs).HasColumnName("nameDisplayAs"); + nameDetails.Property(nd => nd.NameFullTitle).HasColumnName("nameFullTitle"); + }); + constituencyConfiguration.Property(e => e.LastRefresh).HasColumnName("lastRefresh"); + + constituencyConfiguration + .HasOne(c => c.MemberContactDetails) + .WithOne() + .HasForeignKey(c => c.MemberId) + .HasPrincipalKey(m => m.MemberId); } diff --git a/Dfe.Academies.Api.Infrastructure/MopRepository.cs b/Dfe.Academies.Api.Infrastructure/MopRepository.cs index 3442a8faa..effb178e5 100644 --- a/Dfe.Academies.Api.Infrastructure/MopRepository.cs +++ b/Dfe.Academies.Api.Infrastructure/MopRepository.cs @@ -1,12 +1,7 @@ -using Dfe.Academies.Academisation.Data; +using Dfe.Academies.Domain.Interfaces.Repositories; using Dfe.Academies.Infrastructure.Repositories; namespace Dfe.Academies.Infrastructure { - public class MopRepository : Repository where TEntity : class, new() - { - public MopRepository(MopContext dbContext) : base(dbContext) - { - } - } + public class MopRepository(MopContext dbContext) : Repository(dbContext), IMopRepository where TEntity : class, new(); } diff --git a/Dfe.Academies.Api.Infrastructure/MstrContext.cs b/Dfe.Academies.Api.Infrastructure/MstrContext.cs index c5b1e7d39..b59266996 100644 --- a/Dfe.Academies.Api.Infrastructure/MstrContext.cs +++ b/Dfe.Academies.Api.Infrastructure/MstrContext.cs @@ -1,10 +1,9 @@ - -using Dfe.Academies.Domain.Establishment; +using Dfe.Academies.Domain.Establishment; using Dfe.Academies.Domain.Trust; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Dfe.Academies.Academisation.Data; +namespace Dfe.Academies.Infrastructure; public class MstrContext : DbContext { @@ -26,8 +25,10 @@ public MstrContext(DbContextOptions options) : base(options) public DbSet EstablishmentTypes { get; set; } = null!; public DbSet EducationEstablishmentTrusts { get; set; } = null!; public DbSet LocalAuthorities { get; set; } = null!; - public DbSet IfdPipelines { get; set; } = null!; + public DbSet GovernanceRoleTypes { get; set; } = null!; + public DbSet EducationEstablishmentGovernances { get; set; } = null!; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -49,6 +50,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(ConfigureLocalAuthority); modelBuilder.Entity(ConfigureIfdPipeline); + if (Database.IsSqlite()) + { + modelBuilder.Entity(ConfigureEducationEstablishmentGovernance); + modelBuilder.Entity().Metadata.SetIsTableExcludedFromMigrations(false); + + modelBuilder.Entity(ConfigureGovernanceRoleType); + modelBuilder.Entity().Metadata.SetIsTableExcludedFromMigrations(false); + } + else + { + modelBuilder.Entity(ConfigureEducationEstablishmentGovernance); + modelBuilder.Entity().Metadata.SetIsTableExcludedFromMigrations(true); + + modelBuilder.Entity(ConfigureGovernanceRoleType); + modelBuilder.Entity().Metadata.SetIsTableExcludedFromMigrations(true); + } + base.OnModelCreating(modelBuilder); } @@ -56,6 +74,8 @@ private void ConfigureEstablishment(EntityTypeBuilder establishme { establishmentConfiguration.HasKey(e => e.SK); + establishmentConfiguration.Property(e => e.SK).HasColumnName("SK"); + establishmentConfiguration.ToTable("EducationEstablishment", DEFAULT_SCHEMA); establishmentConfiguration.Property(e => e.Diocese).HasColumnName("Diocese"); @@ -158,6 +178,13 @@ private void ConfigureEstablishment(EntityTypeBuilder establishme .HasForeignKey(x => x.LocalAuthorityId) .IsRequired(false); + establishmentConfiguration + .HasMany(e => e.EducationEstablishmentGovernances) + .WithOne(g => g.Establishment) + .HasForeignKey(g => g.EducationEstablishmentId) + .IsRequired(false); + + // No relationship exists yet // Make sure entity framework doesn't generate one establishmentConfiguration.Ignore(x => x.IfdPipeline); @@ -278,5 +305,50 @@ private void ConfigureIfdPipeline(EntityTypeBuilder ifdPipelineConf ifdPipelineConfiguration.Property(e => e.ProjectTemplateInformationViabilityIssue) .HasColumnName("Project template information.Viability issue?"); } + void ConfigureEducationEstablishmentGovernance(EntityTypeBuilder governanceConfiguration) + { + governanceConfiguration.HasKey(e => e.SK); + + governanceConfiguration.ToTable("EducationEstablishmentGovernance", DEFAULT_SCHEMA); + + governanceConfiguration.Property(e => e.EducationEstablishmentId).HasColumnName("FK_EducationEstablishment"); + governanceConfiguration.Property(e => e.GovernanceRoleTypeId).HasColumnName("FK_GovernanceRoleType"); + governanceConfiguration.Property(e => e.GID).HasColumnName("GID").IsRequired(); + governanceConfiguration.Property(e => e.Title).HasColumnName("Title"); + governanceConfiguration.Property(e => e.Forename1).HasColumnName("Forename1"); + governanceConfiguration.Property(e => e.Forename2).HasColumnName("Forename2"); + governanceConfiguration.Property(e => e.Surname).HasColumnName("Surname"); + governanceConfiguration.Property(e => e.Email).HasColumnName("Email"); + governanceConfiguration.Property(e => e.DateOfAppointment).HasColumnName("Date of appointment"); + governanceConfiguration.Property(e => e.DateTermOfOfficeEndsEnded).HasColumnName("Date term of office ends/ended"); + governanceConfiguration.Property(e => e.AppointingBody).HasColumnName("Appointing body"); + governanceConfiguration.Property(e => e.Modified).HasColumnName("Modified"); + governanceConfiguration.Property(e => e.ModifiedBy).HasColumnName("Modified By"); + + // Foreign keys + governanceConfiguration + .HasOne(x => x.Establishment) + .WithMany(e => e.EducationEstablishmentGovernances) + .HasForeignKey(x => x.EducationEstablishmentId); + + governanceConfiguration + .HasOne(x => x.GovernanceRoleType) + .WithMany() + .HasForeignKey(x => x.GovernanceRoleTypeId); + + } -} + + private void ConfigureGovernanceRoleType(EntityTypeBuilder governanceRoleTypeConfiguration) + { + governanceRoleTypeConfiguration.HasKey(e => e.SK); + + governanceRoleTypeConfiguration.ToTable("Ref_GovernanceRoleType", DEFAULT_SCHEMA); + + governanceRoleTypeConfiguration.Property(e => e.SK).HasColumnName("SK"); + governanceRoleTypeConfiguration.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + governanceRoleTypeConfiguration.Property(e => e.Modified).HasColumnName("Modified"); + governanceRoleTypeConfiguration.Property(e => e.ModifiedBy).HasColumnName("Modified By"); + + } +} \ No newline at end of file diff --git a/Dfe.Academies.Api.Infrastructure/MstrRepository.cs b/Dfe.Academies.Api.Infrastructure/MstrRepository.cs new file mode 100644 index 000000000..2130375b0 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/MstrRepository.cs @@ -0,0 +1,7 @@ +using Dfe.Academies.Infrastructure.Repositories; +using Dfe.Academies.Domain.Interfaces.Repositories; + +namespace Dfe.Academies.Infrastructure +{ + public class MstrRepository(MstrContext dbContext) : Repository(dbContext), IMstrRepository where TEntity : class, new(); +} diff --git a/Dfe.Academies.Api.Infrastructure/QueryServices/EstablishmentQueryService.cs b/Dfe.Academies.Api.Infrastructure/QueryServices/EstablishmentQueryService.cs new file mode 100644 index 000000000..daf753c9e --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/QueryServices/EstablishmentQueryService.cs @@ -0,0 +1,28 @@ +using Dfe.Academies.Application.Common.Interfaces; +using Dfe.Academies.Infrastructure.Models; +using Microsoft.EntityFrameworkCore; + +namespace Dfe.Academies.Infrastructure.QueryServices +{ + internal class EstablishmentQueryService(MstrContext context) : IEstablishmentQueryService + { + public IQueryable? GetPersonsAssociatedWithAcademyByUrn(int urn) + { + var establishmentExists = context.Establishments.AsNoTracking().Any(e => e.URN == urn); + if (!establishmentExists) + { + return null; + } + + var query = from ee in context.Establishments.AsNoTracking() + join eeg in context.EducationEstablishmentGovernances.AsNoTracking() + on ee.SK equals eeg.EducationEstablishmentId + join grt in context.GovernanceRoleTypes.AsNoTracking() + on eeg.GovernanceRoleTypeId equals grt.SK + where ee.URN == urn + select new AcademyGovernanceQueryModel(eeg, grt, ee); + + return query; + } + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/CensusDataRepository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/CensusDataRepository.cs index 589bbf8c0..a73765db7 100644 --- a/Dfe.Academies.Api.Infrastructure/Repositories/CensusDataRepository.cs +++ b/Dfe.Academies.Api.Infrastructure/Repositories/CensusDataRepository.cs @@ -1,4 +1,5 @@ using CsvHelper; +using Dfe.Academies.Domain.Interfaces.Repositories; using Dfe.Academies.Domain.Census; using System.Globalization; diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/ConstituencyRepository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/ConstituencyRepository.cs new file mode 100644 index 000000000..3ebe7d59f --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Repositories/ConstituencyRepository.cs @@ -0,0 +1,30 @@ +using Dfe.Academies.Domain.Constituencies; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Dfe.Academies.Infrastructure.Repositories +{ + public class ConstituencyRepository(MopContext context) : IConstituencyRepository + { + public async Task GetMemberOfParliamentByConstituencyAsync(string constituencyName, CancellationToken cancellationToken) + { + return await context.Constituencies + .AsNoTracking() + .Include(c => c.MemberContactDetails) + .Where(c => c.ConstituencyName == constituencyName + && c.MemberContactDetails.TypeId == 1 + && !c.EndDate.HasValue) + .FirstOrDefaultAsync(cancellationToken); + } + + public IQueryable GetMembersOfParliamentByConstituenciesQueryable(List constituencyNames) + { + return context.Constituencies + .AsNoTracking() + .Include(c => c.MemberContactDetails) + .Where(c => constituencyNames.Contains(c.ConstituencyName) + && c.MemberContactDetails.TypeId == 1 + && !c.EndDate.HasValue); + } + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/EducationalPerformanceRepository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/EducationalPerformanceRepository.cs index ffa4e6791..fe8319891 100644 --- a/Dfe.Academies.Api.Infrastructure/Repositories/EducationalPerformanceRepository.cs +++ b/Dfe.Academies.Api.Infrastructure/Repositories/EducationalPerformanceRepository.cs @@ -1,5 +1,6 @@ using Dfe.Academies.Domain.EducationalPerformance; using Microsoft.EntityFrameworkCore; +using Dfe.Academies.Domain.Interfaces.Repositories; namespace Dfe.Academies.Infrastructure.Repositories { diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/EstablishmentRepository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/EstablishmentRepository.cs index a9fc98f9f..452a86368 100644 --- a/Dfe.Academies.Api.Infrastructure/Repositories/EstablishmentRepository.cs +++ b/Dfe.Academies.Api.Infrastructure/Repositories/EstablishmentRepository.cs @@ -1,6 +1,8 @@ -using Dfe.Academies.Academisation.Data; +using Dfe.Academies.Application.Common.Models; using Dfe.Academies.Domain.Establishment; +using Dfe.Academies.Domain.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; +using Dfe.Academies.Infrastructure.Models; namespace Dfe.Academies.Infrastructure.Repositories { @@ -140,6 +142,7 @@ private static Establishment ToEstablishment(EstablishmentQueryResult queryResul return result; } + } internal record EstablishmentQueryResult diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs index c742de607..9b6c3d7cc 100644 --- a/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs +++ b/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs @@ -1,11 +1,11 @@ -using Dfe.Academies.Domain.Repositories; +using Dfe.Academies.Domain.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; namespace Dfe.Academies.Infrastructure.Repositories { - #pragma warning disable CS8603 // Possible null reference return, behaviour expected +#pragma warning disable CS8603 // Possible null reference return, behaviour expected [ExcludeFromCodeCoverage] public abstract class Repository : IRepository where TEntity : class, new() @@ -20,7 +20,7 @@ public abstract class Repository : IRepository /// protected Repository(TDbContext dbContext) => this.DbContext = dbContext; - /// Short hand for _dbContext.Set + /// Shorthand for _dbContext.Set /// protected virtual DbSet DbSet() { @@ -76,7 +76,8 @@ public virtual TEntity Get(Expression> predicate) /// public virtual TEntity Get(params object[] keyValues) { - return this.Find(keyValues) ?? throw new InvalidOperationException(string.Format("Entity type {0} is null for primary key {1}", (object)typeof(TEntity), (object)keyValues)); + return this.Find(keyValues) ?? throw new InvalidOperationException( + $"Entity type {(object)typeof(TEntity)} is null for primary key {(object)keyValues}"); } /// @@ -88,7 +89,8 @@ public virtual async Task GetAsync(Expression> pred /// public virtual async Task GetAsync(params object[] keyValues) { - return await this.FindAsync(keyValues) ?? throw new InvalidOperationException(string.Format("Entity type {0} is null for primary key {1}", (object)typeof(TEntity), (object)keyValues)); + return await this.FindAsync(keyValues) ?? throw new InvalidOperationException( + $"Entity type {(object)typeof(TEntity)} is null for primary key {(object)keyValues}"); } /// diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/TrustRepository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/TrustRepository.cs index d002f1d6d..7099caed7 100644 --- a/Dfe.Academies.Api.Infrastructure/Repositories/TrustRepository.cs +++ b/Dfe.Academies.Api.Infrastructure/Repositories/TrustRepository.cs @@ -1,6 +1,6 @@ -using Dfe.Academies.Academisation.Data; -using Dfe.Academies.Domain.Trust; +using Dfe.Academies.Domain.Trust; using Microsoft.EntityFrameworkCore; +using Dfe.Academies.Domain.Interfaces.Repositories; namespace Dfe.Academies.Infrastructure.Repositories { diff --git a/Dfe.Academies.Api.Infrastructure/Security/ApiKeyOrRoleHandler.cs b/Dfe.Academies.Api.Infrastructure/Security/ApiKeyOrRoleHandler.cs new file mode 100644 index 000000000..8e38b3d31 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Security/ApiKeyOrRoleHandler.cs @@ -0,0 +1,45 @@ +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Infrastructure.Security.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Infrastructure.Security +{ + /// + /// Temporary workaround to allow API Key authentication, will be removed once all clients use Client Credentials + /// + [ExcludeFromCodeCoverage] + public class ApiKeyOrRoleHandler(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : AuthorizationHandler + { + private const string ApiKeyHeaderName = "ApiKey"; + private readonly List? _configuredApiKeys = configuration.GetSection("ApiKeys").Get>(); + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ApiKeyOrRoleRequirement requirement) + { + // Check API Key + if (httpContextAccessor.HttpContext!.Request.Headers.TryGetValue(ApiKeyHeaderName, out StringValues apiKeyHeader)) + { + + var key = _configuredApiKeys?.Find(user => user.ApiKey!.Equals(apiKeyHeader)); + + if (key != null) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + } + + // Check Role-based authorization + if (context?.User != null && context.User.IsInRole(requirement.RolePolicy)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Security/Authorization/ApiKeyOrRoleRequirement.cs b/Dfe.Academies.Api.Infrastructure/Security/Authorization/ApiKeyOrRoleRequirement.cs new file mode 100644 index 000000000..ab62ea97e --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Security/Authorization/ApiKeyOrRoleRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Infrastructure.Security.Authorization +{ + [ExcludeFromCodeCoverage] + public class ApiKeyOrRoleRequirement(string rolePolicy) : IAuthorizationRequirement + { + public string RolePolicy { get; } = rolePolicy; + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Security/Authorization/AuthorizationExtensions.cs b/Dfe.Academies.Api.Infrastructure/Security/Authorization/AuthorizationExtensions.cs new file mode 100644 index 000000000..4ad476f93 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Security/Authorization/AuthorizationExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Web; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Infrastructure.Security.Authorization +{ + [ExcludeFromCodeCoverage] + public static class AuthorizationExtensions + { + public static IServiceCollection AddCustomAuthorization(this IServiceCollection services, IConfiguration configuration) + { + // Add both Azure AD (JWT) and API Key authentication mechanisms + services.AddAuthentication(options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd")); + + services.AddAuthorization(options => + { + var roles = configuration.GetSection("Authorization:Roles").Get(); + if (roles != null) + { + foreach (var role in roles) + { + options.AddPolicy(role, policy => + { + policy.Requirements.Add(new ApiKeyOrRoleRequirement(role)); + }); + } + } + + // Add claim-based policies + var claims = configuration.GetSection("Authorization:Claims").Get>(); + + if (claims == null) return; + + foreach (var claim in claims) + { + options.AddPolicy($"{claim.Key}", policy => + policy.RequireClaim(claim.Key, claim.Value)); + } + }); + + services.AddSingleton(); + + return services; + } + } +} diff --git a/Dfe.Academies.Application/ApplicationServiceCollectionExtensions.cs b/Dfe.Academies.Application/ApplicationServiceCollectionExtensions.cs new file mode 100644 index 000000000..b25294557 --- /dev/null +++ b/Dfe.Academies.Application/ApplicationServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Dfe.Academies.Application.Common.Behaviours; +using Dfe.Academies.Application.EducationalPerformance; +using Dfe.Academies.Application.Establishment; +using Dfe.Academies.Application.Trust; +using MediatR; +using Microsoft.Extensions.Configuration; +using System.Reflection; +using FluentValidation; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ApplicationServiceCollectionExtensions + { + public static IServiceCollection AddApplicationDependencyGroup( + this IServiceCollection services, IConfiguration config) + { + //Queries + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddPersonsApiApplicationDependencyGroup( + this IServiceCollection services, IConfiguration config) + { + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>)); + }); + + return services; + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs b/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs deleted file mode 100644 index 46ebea845..000000000 --- a/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Dfe.Academies.Academisation.Data; -using Dfe.Academies.Application.EducationalPerformance; -using Dfe.Academies.Application.Establishment; -using Dfe.Academies.Application.MappingProfiles; -using Dfe.Academies.Application.Persons; -using Dfe.Academies.Application.Trust; -using Dfe.Academies.Domain.Census; -using Dfe.Academies.Domain.EducationalPerformance; -using Dfe.Academies.Domain.Establishment; -using Dfe.Academies.Domain.Persons; -using Dfe.Academies.Domain.Repositories; -using Dfe.Academies.Domain.Trust; -using Dfe.Academies.Infrastructure; -using Dfe.Academies.Infrastructure.Repositories; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; - -namespace Microsoft.Extensions.DependencyInjection -{ - public static class ApplicationServiceCollectionExtensions - { - //public static IServiceCollection AddConfig( - // this IServiceCollection services, IConfiguration config) - //{ - // services.Configure( - // config.GetSection(PositionOptions.Position)); - // services.Configure( - // config.GetSection(ColorOptions.Color)); - - // return services; - //} - - public static IServiceCollection AddApplicationDependencyGroup( - this IServiceCollection services, IConfiguration config) - { - //Queries - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - - //Repos - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddTransient(typeof(IRepository<>), typeof(MopRepository<>)); - - //Db - services.AddDbContext(options => - options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); - - services.AddDbContext(options => - options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); - - services.AddDbContext(options => - options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); - - return services; - } - } -} \ No newline at end of file diff --git a/Dfe.Academies.Application/Common/Behaviours/PerformanceBehaviour.cs b/Dfe.Academies.Application/Common/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 000000000..327a411f0 --- /dev/null +++ b/Dfe.Academies.Application/Common/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,42 @@ +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Application.Common.Behaviours +{ + [ExcludeFromCodeCoverage] + public class PerformanceBehaviour( + ILogger logger, + IHttpContextAccessor httpContextAccessor) + : IPipelineBehavior + where TRequest : notnull + { + private readonly Stopwatch _timer = new(); + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await next(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds <= 500) return response; + + var user = httpContextAccessor.HttpContext?.User; + + var requestName = typeof(TRequest).Name; + var identityName = user?.Identity?.Name; + + logger.LogWarning("PersonsAPI Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@IdentityName} {@Request}", + requestName, elapsedMilliseconds, identityName, request); + + return response; + } + } + +} diff --git a/Dfe.Academies.Application/Common/Behaviours/UnhandledExceptionBehaviour.cs b/Dfe.Academies.Application/Common/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 000000000..e3f3ca283 --- /dev/null +++ b/Dfe.Academies.Application/Common/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,29 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Application.Common.Behaviours +{ + [ExcludeFromCodeCoverage] + public class UnhandledExceptionBehaviour(ILogger logger) + : IPipelineBehavior + where TRequest : notnull + { + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + + logger.LogError(ex, "PersonsAPI Request: Unhandled Exception for Request {Name} {@Request}", requestName, request); + + throw; + } + } + } + +} diff --git a/Dfe.Academies.Application/Common/Behaviours/ValidationBehaviour.cs b/Dfe.Academies.Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 000000000..07b927372 --- /dev/null +++ b/Dfe.Academies.Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using MediatR; +using System.Diagnostics.CodeAnalysis; +using ValidationException = Dfe.Academies.Application.Common.Exceptions.ValidationException; + +namespace Dfe.Academies.Application.Common.Behaviours; + +[ExcludeFromCodeCoverage] +public class ValidationBehaviour(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!validators.Any()) return await next(); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + validators.Select(v => + v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Count > 0) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count > 0) + throw new ValidationException(failures); + return await next(); + } +} diff --git a/Dfe.Academies.Application/Common/Exceptions/ValidationException.cs b/Dfe.Academies.Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 000000000..427995fd3 --- /dev/null +++ b/Dfe.Academies.Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,20 @@ +using FluentValidation.Results; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.Academies.Application.Common.Exceptions +{ + [ExcludeFromCodeCoverage] + public class ValidationException() : Exception("One or more validation failures have occurred.") + { + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public IDictionary Errors { get; } = new Dictionary(); + } + +} diff --git a/Dfe.Academies.Application/Common/Interfaces/IEstablishmentQueryService.cs b/Dfe.Academies.Application/Common/Interfaces/IEstablishmentQueryService.cs new file mode 100644 index 000000000..c845c6308 --- /dev/null +++ b/Dfe.Academies.Application/Common/Interfaces/IEstablishmentQueryService.cs @@ -0,0 +1,9 @@ +using Dfe.Academies.Infrastructure.Models; + +namespace Dfe.Academies.Application.Common.Interfaces +{ + public interface IEstablishmentQueryService + { + IQueryable? GetPersonsAssociatedWithAcademyByUrn(int urn); + } +} diff --git a/Dfe.Academies.Application/Common/Models/AcademyGovernance.cs b/Dfe.Academies.Application/Common/Models/AcademyGovernance.cs new file mode 100644 index 000000000..553687caa --- /dev/null +++ b/Dfe.Academies.Application/Common/Models/AcademyGovernance.cs @@ -0,0 +1,8 @@ +namespace Dfe.Academies.Application.Common.Models +{ + public class AcademyGovernance : Person + { + public string? UKPRN { get; set; } + public int? URN { get; set; } + } +} diff --git a/Dfe.Academies.Application/Common/Models/AcademyGovernanceQueryModel.cs b/Dfe.Academies.Application/Common/Models/AcademyGovernanceQueryModel.cs new file mode 100644 index 000000000..d53cea071 --- /dev/null +++ b/Dfe.Academies.Application/Common/Models/AcademyGovernanceQueryModel.cs @@ -0,0 +1,10 @@ +using Dfe.Academies.Domain.Establishment; + +namespace Dfe.Academies.Infrastructure.Models +{ + public record AcademyGovernanceQueryModel( + EducationEstablishmentGovernance EducationEstablishmentGovernance, + GovernanceRoleType GovernanceRoleType, + Establishment Establishment + ); +} diff --git a/Dfe.Academies.Application/Common/Models/ApiUser.cs b/Dfe.Academies.Application/Common/Models/ApiUser.cs new file mode 100644 index 000000000..76083620f --- /dev/null +++ b/Dfe.Academies.Application/Common/Models/ApiUser.cs @@ -0,0 +1,8 @@ +namespace Dfe.Academies.Application.Common.Models +{ + public class ApiUser + { + public string? UserName { get; set; } + public string? ApiKey { get; set; } + } +} diff --git a/Dfe.Academies.Application/Common/Models/MemberOfParliament.cs b/Dfe.Academies.Application/Common/Models/MemberOfParliament.cs new file mode 100644 index 000000000..9842d5e8a --- /dev/null +++ b/Dfe.Academies.Application/Common/Models/MemberOfParliament.cs @@ -0,0 +1,7 @@ +namespace Dfe.Academies.Application.Common.Models +{ + public class MemberOfParliament : Person + { + public required string ConstituencyName { get; set; } + } +} diff --git a/Dfe.Academies.Application/Common/Models/Person.cs b/Dfe.Academies.Application/Common/Models/Person.cs new file mode 100644 index 000000000..b7e13ac02 --- /dev/null +++ b/Dfe.Academies.Application/Common/Models/Person.cs @@ -0,0 +1,22 @@ +namespace Dfe.Academies.Application.Common.Models +{ + public class Person + { + private ICollection? _roles; + + public int Id { get; set; } + public required string FirstName { get; set; } + public required string LastName { get; set; } + public required string Email { get; set; } + public required string DisplayName { get; set; } + public required string DisplayNameWithTitle { get; set; } + public string? Phone { get; set; } + public required ICollection Roles + { + get => _roles ??= new List(); + set => _roles = value ?? []; + } + public required DateTime? UpdatedAt { get; set; } + + } +} diff --git a/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituencies.cs b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituencies.cs new file mode 100644 index 000000000..4c6d362f8 --- /dev/null +++ b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituencies.cs @@ -0,0 +1,40 @@ +using AutoMapper; +using MediatR; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Utils.Caching; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Domain.Interfaces.Repositories; + +namespace Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituencies +{ + public record GetMembersOfParliamentByConstituenciesQuery(List ConstituencyNames) : IRequest>; + + public class GetMembersOfParliamentByConstituenciesQueryHandler( + IConstituencyRepository constituencyRepository, + IMapper mapper, + ICacheService cacheService) + : IRequestHandler> + { + public async Task> Handle(GetMembersOfParliamentByConstituenciesQuery request, CancellationToken cancellationToken) + { + var cacheKey = $"MemberOfParliament_{CacheKeyHelper.GenerateHashedCacheKey(request.ConstituencyNames)}"; + + var methodName = nameof(GetMembersOfParliamentByConstituenciesQueryHandler); + + return await cacheService.GetOrAddAsync(cacheKey, async () => + { + var constituenciesQuery = constituencyRepository + .GetMembersOfParliamentByConstituenciesQueryable(request.ConstituencyNames); + + var membersOfParliament = await constituenciesQuery + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(cancellationToken); + + return membersOfParliament; + }, methodName); + } + } + +} diff --git a/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituenciesValidator.cs b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituenciesValidator.cs new file mode 100644 index 000000000..d81624d5f --- /dev/null +++ b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituencies/GetMemberOfParliamentByConstituenciesValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituencies +{ + public class GetMemberOfParliamentByConstituenciesValidator : AbstractValidator + { + public GetMemberOfParliamentByConstituenciesValidator() + { + RuleFor(x => x.ConstituencyNames) + .NotNull().WithMessage("Constituency names cannot be null.") + .NotEmpty().WithMessage("Constituency names cannot be empty.") + .Must(c => c.Count > 0).WithMessage("At least one constituency must be provided."); + } + } +} diff --git a/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituency.cs b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituency.cs new file mode 100644 index 000000000..deb29fe9b --- /dev/null +++ b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituency.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using Dfe.Academies.Application.Common.Models; +using MediatR; +using Dfe.Academies.Utils.Caching; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Domain.Interfaces.Repositories; + +namespace Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituency +{ + public record GetMemberOfParliamentByConstituencyQuery(string ConstituencyName) : IRequest; + + public class GetMemberOfParliamentByConstituencyQueryHandler( + IConstituencyRepository constituencyRepository, + IMapper mapper, + ICacheService cacheService) + : IRequestHandler + { + public async Task Handle(GetMemberOfParliamentByConstituencyQuery request, CancellationToken cancellationToken) + { + var cacheKey = $"MemberOfParliament_{CacheKeyHelper.GenerateHashedCacheKey(request.ConstituencyName)}"; + + var methodName = nameof(GetMemberOfParliamentByConstituencyQueryHandler); + + return await cacheService.GetOrAddAsync(cacheKey, async () => + { + var constituencyWithMember = await constituencyRepository + .GetMemberOfParliamentByConstituencyAsync(request.ConstituencyName, cancellationToken); + + var result = mapper.Map(constituencyWithMember); + + return result; + }, methodName); + } + } +} diff --git a/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituencyQueryValidator.cs b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituencyQueryValidator.cs new file mode 100644 index 000000000..8991e2bdb --- /dev/null +++ b/Dfe.Academies.Application/Constituencies/Queries/GetMemberOfParliamentByConstituency/GetMemberOfParliamentByConstituencyQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituency +{ + public class GetMemberOfParliamentByConstituencyQueryValidator : AbstractValidator + { + public GetMemberOfParliamentByConstituencyQueryValidator() + { + RuleFor(x => x.ConstituencyName) + .NotNull().WithMessage("Constituency name cannot be null.") + .NotEmpty().WithMessage("Constituency name cannot be empty."); + } + } +} diff --git a/Dfe.Academies.Application/Dfe.Academies.Application.csproj b/Dfe.Academies.Application/Dfe.Academies.Application.csproj index ba4840010..e841ad797 100644 --- a/Dfe.Academies.Application/Dfe.Academies.Application.csproj +++ b/Dfe.Academies.Application/Dfe.Academies.Application.csproj @@ -9,12 +9,15 @@ + + + + - diff --git a/Dfe.Academies.Application/EducationalPerformance/EducationalPerformanceQueries.cs b/Dfe.Academies.Application/EducationalPerformance/EducationalPerformanceQueries.cs index 9ca249577..2c9c8c2a9 100644 --- a/Dfe.Academies.Application/EducationalPerformance/EducationalPerformanceQueries.cs +++ b/Dfe.Academies.Application/EducationalPerformance/EducationalPerformanceQueries.cs @@ -1,4 +1,5 @@ -using Dfe.Academies.Contracts.V1.EducationalPerformance; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Dfe.Academies.Contracts.V1.EducationalPerformance; using Dfe.Academies.Domain.EducationalPerformance; namespace Dfe.Academies.Application.EducationalPerformance diff --git a/Dfe.Academies.Application/Establishment/EstablishmentQueries.cs b/Dfe.Academies.Application/Establishment/EstablishmentQueries.cs index 146fd1251..f91886d5b 100644 --- a/Dfe.Academies.Application/Establishment/EstablishmentQueries.cs +++ b/Dfe.Academies.Application/Establishment/EstablishmentQueries.cs @@ -1,7 +1,5 @@ using Dfe.Academies.Contracts.V4.Establishments; -using Dfe.Academies.Domain.Census; -using Dfe.Academies.Domain.Establishment; -using Dfe.Academies.Domain.Trust; +using Dfe.Academies.Domain.Interfaces.Repositories; namespace Dfe.Academies.Application.Establishment { diff --git a/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrn.cs b/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrn.cs new file mode 100644 index 000000000..e3d9e1339 --- /dev/null +++ b/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrn.cs @@ -0,0 +1,43 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Dfe.Academies.Application.Common.Interfaces; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Utils.Caching; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Dfe.Academies.Application.Establishment.Queries.GetAllPersonsAssociatedWithAcademyByUrn +{ + public record GetAllPersonsAssociatedWithAcademyByUrnQuery(int Urn) : IRequest?>; + + public class GetAllPersonsAssociatedWithAcademyByUrnQueryHandler( + IEstablishmentQueryService establishmentQueryService, + IMapper mapper, + ICacheService cacheService) + : IRequestHandler?> + { + public async Task?> Handle(GetAllPersonsAssociatedWithAcademyByUrnQuery request, CancellationToken cancellationToken) + { + var cacheKey = $"PersonsAssociatedWithAcademy_{CacheKeyHelper.GenerateHashedCacheKey(request.Urn.ToString())}"; + + var methodName = nameof(GetAllPersonsAssociatedWithAcademyByUrnQueryHandler); + + return await cacheService.GetOrAddAsync(cacheKey, async () => + { + var query = establishmentQueryService.GetPersonsAssociatedWithAcademyByUrn(request.Urn); + + if (query == null) + { + return null; + } + + var result = await query + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(cancellationToken); + + return result; + }, methodName); + } + } +} diff --git a/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrnValidator.cs b/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrnValidator.cs new file mode 100644 index 000000000..00beb8285 --- /dev/null +++ b/Dfe.Academies.Application/Establishment/Queries/GetAllPersonsAssociatedWithAcademyByUrn/GetAllPersonsAssociatedWithAcademyByUrnValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Dfe.Academies.Application.Establishment.Queries.GetAllPersonsAssociatedWithAcademyByUrn +{ + public class GetAllPersonsAssociatedWithAcademyByUrnValidator : AbstractValidator + { + public GetAllPersonsAssociatedWithAcademyByUrnValidator() + { + RuleFor(query => query.Urn) + .GreaterThan(0).WithMessage("URN must be greater than 0.") + .NotEmpty().WithMessage("URN is required."); + } + } +} diff --git a/Dfe.Academies.Application/MappingProfiles/AcademyWithGovernanceProfile.cs b/Dfe.Academies.Application/MappingProfiles/AcademyWithGovernanceProfile.cs new file mode 100644 index 000000000..6ef42e8e2 --- /dev/null +++ b/Dfe.Academies.Application/MappingProfiles/AcademyWithGovernanceProfile.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Infrastructure.Models; + +namespace Dfe.Academies.Application.MappingProfiles +{ + public class AcademyWithGovernanceProfile : Profile + { + public AcademyWithGovernanceProfile() + { + CreateMap() + .ForMember(dest => dest.Id, opt => opt.MapFrom(src => (int)src.EducationEstablishmentGovernance.SK)) + .ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.EducationEstablishmentGovernance.Forename1)) + .ForMember(dest => dest.LastName, opt => opt.MapFrom(src => src.EducationEstablishmentGovernance.Surname)) + .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.EducationEstablishmentGovernance.Email)) + .ForMember(dest => dest.DisplayName, opt => opt.MapFrom(src => $"{src.EducationEstablishmentGovernance.Forename1} {src.EducationEstablishmentGovernance.Surname}")) + .ForMember(dest => dest.DisplayNameWithTitle, opt => opt.MapFrom(src => $"{src.EducationEstablishmentGovernance.Title} {src.EducationEstablishmentGovernance.Forename1} {src.EducationEstablishmentGovernance.Surname}")) + .ForMember(dest => dest.Roles, opt => opt.MapFrom(src => new List { src.GovernanceRoleType.Name })) + .ForMember(dest => dest.UpdatedAt, opt => opt.MapFrom(src => src.EducationEstablishmentGovernance.Modified)); + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.Application/MappingProfiles/ConstituencyProfile.cs b/Dfe.Academies.Application/MappingProfiles/ConstituencyProfile.cs new file mode 100644 index 000000000..a94c60057 --- /dev/null +++ b/Dfe.Academies.Application/MappingProfiles/ConstituencyProfile.cs @@ -0,0 +1,24 @@ +using AutoMapper; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Domain.Constituencies; + +namespace Dfe.Academies.Application.MappingProfiles +{ + public class ConstituencyProfile : Profile + { + public ConstituencyProfile() + { + CreateMap() + .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.MemberId.Value)) + .ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.NameDetails.NameListAs.Split(",", StringSplitOptions.None)[1].Trim())) + .ForMember(dest => dest.LastName, opt => opt.MapFrom(src => src.NameDetails.NameListAs.Split(",", StringSplitOptions.None)[0].Trim())) + .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.MemberContactDetails.Email)) + .ForMember(dest => dest.DisplayName, opt => opt.MapFrom(src => src.NameDetails.NameDisplayAs)) + .ForMember(dest => dest.DisplayNameWithTitle, opt => opt.MapFrom(src => src.NameDetails.NameFullTitle)) + .ForMember(dest => dest.Roles, opt => opt.MapFrom(src => new List { "Member of Parliament" })) + .ForMember(dest => dest.UpdatedAt, opt => opt.MapFrom(src => src.LastRefresh)) + .ForMember(dest => dest.Phone, opt => opt.MapFrom(src => src.MemberContactDetails.Phone)) + .ForMember(dest => dest.ConstituencyName, opt => opt.MapFrom(src => src.ConstituencyName)); + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs b/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs deleted file mode 100644 index 08789684d..000000000 --- a/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AutoMapper; -using Dfe.Academies.Application.Models; - -namespace Dfe.Academies.Application.MappingProfiles -{ - public class PersonProfile : Profile - { - public PersonProfile() - { - CreateMap() - .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Constituency.MemberID)) - .ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.Constituency.NameList.Split(",", StringSplitOptions.None)[1].Trim())) - .ForMember(dest => dest.LastName, opt => opt.MapFrom(src => src.Constituency.NameList.Split(",", StringSplitOptions.None)[0].Trim())) - .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.MemberContactDetails.Email)) - .ForMember(dest => dest.DisplayName, opt => opt.MapFrom(src => src.Constituency.NameDisplayAs)) - .ForMember(dest => dest.DisplayNameWithTitle, opt => opt.MapFrom(src => src.Constituency.NameFullTitle)) - .ForMember(dest => dest.Role, opt => opt.MapFrom(src => "Member of Parliament")) - .ForMember(dest => dest.ConstituencyName, opt => opt.MapFrom(src => src.Constituency.ConstituencyName)); - } - } -} diff --git a/Dfe.Academies.Application/Models/ConstituencyWithMemberContactDetails.cs b/Dfe.Academies.Application/Models/ConstituencyWithMemberContactDetails.cs deleted file mode 100644 index 60fcb9e6e..000000000 --- a/Dfe.Academies.Application/Models/ConstituencyWithMemberContactDetails.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Dfe.Academies.Domain.Persons; - -namespace Dfe.Academies.Application.Models -{ - public record ConstituencyWithMemberContactDetails(Constituency Constituency, MemberContactDetails MemberContactDetails); -} diff --git a/Dfe.Academies.Application/Models/MemberOfParliament.cs b/Dfe.Academies.Application/Models/MemberOfParliament.cs deleted file mode 100644 index 8b7436738..000000000 --- a/Dfe.Academies.Application/Models/MemberOfParliament.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Dfe.Academies.Application.Models -{ - public class MemberOfParliament - { - public int Id { get; set; } - public required string FirstName { get; set; } - public required string LastName { get; set; } - public required string Email { get; set; } - public required string DisplayName { get; set; } - public required string DisplayNameWithTitle { get; set; } - public required string Role { get; set; } - public required string ConstituencyName { get; set; } - } -} diff --git a/Dfe.Academies.Application/Persons/IPersonsQueries.cs b/Dfe.Academies.Application/Persons/IPersonsQueries.cs deleted file mode 100644 index c4942740d..000000000 --- a/Dfe.Academies.Application/Persons/IPersonsQueries.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Dfe.Academies.Application.Models; - -namespace Dfe.Academies.Application.Persons -{ - public interface IPersonsQueries - { - Task GetMemberOfParliamentByConstituencyAsync(string constituencyName, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/Dfe.Academies.Application/Persons/PersonsQueries.cs b/Dfe.Academies.Application/Persons/PersonsQueries.cs deleted file mode 100644 index 4bb7887cf..000000000 --- a/Dfe.Academies.Application/Persons/PersonsQueries.cs +++ /dev/null @@ -1,46 +0,0 @@ -using AutoMapper; -using AutoMapper.QueryableExtensions; -using Dfe.Academies.Application.Models; -using Dfe.Academies.Domain.Persons; -using Dfe.Academies.Domain.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace Dfe.Academies.Application.Persons -{ - public class PersonsQueries : IPersonsQueries - { - private readonly IRepository _constituencyRepository; - private readonly IRepository _MemberContactDetailsRepository; - private readonly IMapper _mapper; - - public PersonsQueries( - IRepository constituencyRepository, - IRepository memberContactDetailsRepository, - IMapper mapper) - { - _constituencyRepository = constituencyRepository; - _MemberContactDetailsRepository = memberContactDetailsRepository; - _mapper = mapper; - } - - public async Task GetMemberOfParliamentByConstituencyAsync(string constituencyName, CancellationToken cancellationToken) - { - var query = from constituencies in _constituencyRepository.Query() - join memberContactDetails in _MemberContactDetailsRepository.Query() - on constituencies.MemberID equals memberContactDetails.MemberID - where constituencies.ConstituencyName == constituencyName - && memberContactDetails.TypeId == 1 - && !constituencies.EndDate.HasValue - select new ConstituencyWithMemberContactDetails(constituencies, memberContactDetails); - - var result = await query - .ProjectTo(_mapper.ConfigurationProvider) - .FirstOrDefaultAsync(cancellationToken); - - return result; - } - - - - } -} diff --git a/Dfe.Academies.Application/Trust/TrustQueries.cs b/Dfe.Academies.Application/Trust/TrustQueries.cs index f3f9b746f..76de537be 100644 --- a/Dfe.Academies.Application/Trust/TrustQueries.cs +++ b/Dfe.Academies.Application/Trust/TrustQueries.cs @@ -1,4 +1,5 @@ -using Dfe.Academies.Contracts.V4; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Dfe.Academies.Contracts.V4; using Dfe.Academies.Contracts.V4.Trusts; using Dfe.Academies.Domain.Trust; diff --git a/Dfe.Academies.Domain/Common/ValueObject.cs b/Dfe.Academies.Domain/Common/ValueObject.cs new file mode 100644 index 000000000..1494d5014 --- /dev/null +++ b/Dfe.Academies.Domain/Common/ValueObject.cs @@ -0,0 +1,45 @@ +namespace Dfe.Academies.Domain.Common +{ + public abstract class ValueObject + { + protected static bool EqualOperator(ValueObject left, ValueObject right) + { + if (left is null ^ right is null) + { + return false; + } + + return left?.Equals(right!) != false; + } + + protected static bool NotEqualOperator(ValueObject left, ValueObject right) + { + return !(EqualOperator(left, right)); + } + + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj == null || obj.GetType() != GetType()) + { + return false; + } + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + + foreach (var component in GetEqualityComponents()) + { + hash.Add(component); + } + + return hash.ToHashCode(); + } + } +} diff --git a/Dfe.Academies.Domain/Constituencies/Constituency.cs b/Dfe.Academies.Domain/Constituencies/Constituency.cs new file mode 100644 index 000000000..e8747ea08 --- /dev/null +++ b/Dfe.Academies.Domain/Constituencies/Constituency.cs @@ -0,0 +1,39 @@ +using Dfe.Academies.Domain.ValueObjects; + +namespace Dfe.Academies.Domain.Constituencies +{ +#pragma warning disable CS8618 + public class Constituency + { + public ConstituencyId ConstituencyId { get; private set; } + public MemberId MemberId { get; private set; } + public string ConstituencyName { get; private set; } + public NameDetails NameDetails { get; private set; } + public DateTime LastRefresh { get; private set; } + public DateOnly? EndDate { get; private set; } + + public Constituency() { } + + public virtual MemberContactDetails MemberContactDetails { get; private set; } + + public Constituency( + ConstituencyId constituencyId, + MemberId memberId, + string constituencyName, + NameDetails nameDetails, + DateTime lastRefresh, + DateOnly? endDate, + MemberContactDetails memberContactDetails) + { + ConstituencyId = constituencyId ?? throw new ArgumentNullException(nameof(constituencyId)); + MemberId = memberId ?? throw new ArgumentNullException(nameof(memberId)); + ConstituencyName = constituencyName; + NameDetails = nameDetails ?? throw new ArgumentNullException(nameof(nameDetails)); + LastRefresh = lastRefresh; + EndDate = endDate; + MemberContactDetails = memberContactDetails; + } + } +#pragma warning restore CS8618 + +} diff --git a/Dfe.Academies.Domain/Constituencies/MemberContactDetails.cs b/Dfe.Academies.Domain/Constituencies/MemberContactDetails.cs new file mode 100644 index 000000000..415cb2e83 --- /dev/null +++ b/Dfe.Academies.Domain/Constituencies/MemberContactDetails.cs @@ -0,0 +1,33 @@ +using Dfe.Academies.Domain.ValueObjects; + +namespace Dfe.Academies.Domain.Constituencies +{ +#pragma warning disable CS8618 + + public class MemberContactDetails + { + public MemberId MemberId { get; private set; } + public string? Email { get; private set; } + public string? Phone { get; private set; } + public int TypeId { get; private set; } + + private MemberContactDetails() { } + + public MemberContactDetails( + MemberId memberId, + int typeId, + string? email = null, + string? phone = null) + { + if (memberId == null) throw new ArgumentNullException(nameof(memberId)); + if (typeId <= 0) throw new ArgumentException("TypeId must be positive", nameof(typeId)); + + MemberId = memberId; + TypeId = typeId; + Email = email; + Phone = phone; + } + } +#pragma warning restore CS8618 + +} diff --git a/Dfe.Academies.Domain/Dfe.Academies.Domain.csproj b/Dfe.Academies.Domain/Dfe.Academies.Domain.csproj index 5eb411b04..7a5397523 100644 --- a/Dfe.Academies.Domain/Dfe.Academies.Domain.csproj +++ b/Dfe.Academies.Domain/Dfe.Academies.Domain.csproj @@ -8,6 +8,7 @@ + diff --git a/Dfe.Academies.Domain/EducationalPerformance/IEducationalPerformanceRepository.cs b/Dfe.Academies.Domain/EducationalPerformance/IEducationalPerformanceRepository.cs deleted file mode 100644 index f974aef08..000000000 --- a/Dfe.Academies.Domain/EducationalPerformance/IEducationalPerformanceRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Dfe.Academies.Domain.Census; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Dfe.Academies.Domain.EducationalPerformance -{ - public interface IEducationalPerformanceRepository - { - public Task> GetSchoolAbsencesByURN(string urn, CancellationToken cancellationToken); - } -} diff --git a/Dfe.Academies.Domain/EducationalPerformance/SchoolAbsence.cs b/Dfe.Academies.Domain/EducationalPerformance/SchoolAbsence.cs index 93fedf74c..1fb547202 100644 --- a/Dfe.Academies.Domain/EducationalPerformance/SchoolAbsence.cs +++ b/Dfe.Academies.Domain/EducationalPerformance/SchoolAbsence.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Dfe.Academies.Domain.EducationalPerformance +namespace Dfe.Academies.Domain.EducationalPerformance { public class SchoolAbsence { diff --git a/Dfe.Academies.Domain/Establishment/EducationEstablishmentGovernance.cs b/Dfe.Academies.Domain/Establishment/EducationEstablishmentGovernance.cs new file mode 100644 index 000000000..2e97a8e35 --- /dev/null +++ b/Dfe.Academies.Domain/Establishment/EducationEstablishmentGovernance.cs @@ -0,0 +1,24 @@ +namespace Dfe.Academies.Domain.Establishment +{ + public class EducationEstablishmentGovernance + { + public long SK { get; set; } + public long? EducationEstablishmentId { get; set; } + public long? GovernanceRoleTypeId { get; set; } + public string? GID { get; set; } + public string? Title { get; set; } + public string? Forename1 { get; set; } + public string? Forename2 { get; set; } + public string? Surname { get; set; } + public string? Email { get; set; } + public string? DateOfAppointment { get; set; } + public string? DateTermOfOfficeEndsEnded { get; set; } + public string? AppointingBody { get; set; } + public DateTime? Modified { get; set; } + public string? ModifiedBy { get; set; } + + public virtual Establishment? Establishment { get; set; } + public virtual GovernanceRoleType? GovernanceRoleType { get; set; } + } + +} diff --git a/Dfe.Academies.Domain/Establishment/Establishment.cs b/Dfe.Academies.Domain/Establishment/Establishment.cs index 19898d7b8..e73f2e162 100644 --- a/Dfe.Academies.Domain/Establishment/Establishment.cs +++ b/Dfe.Academies.Domain/Establishment/Establishment.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Collections.ObjectModel; namespace Dfe.Academies.Domain.Establishment { @@ -99,6 +95,7 @@ public class Establishment public int? SenUnitCapacity { get; set; } public int? SenUnitOnRoll { get; set; } + public virtual ICollection EducationEstablishmentGovernances { get; set; } public LocalAuthority? LocalAuthority { get; set; } public EstablishmentType? EstablishmentType{ get; set; } diff --git a/Dfe.Academies.Domain/Establishment/EstablishmentType.cs b/Dfe.Academies.Domain/Establishment/EstablishmentType.cs index ab1f5dc24..014740c47 100644 --- a/Dfe.Academies.Domain/Establishment/EstablishmentType.cs +++ b/Dfe.Academies.Domain/Establishment/EstablishmentType.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Dfe.Academies.Domain.Establishment +namespace Dfe.Academies.Domain.Establishment { public class EstablishmentType { diff --git a/Dfe.Academies.Domain/Establishment/GovernanceRoleType.cs b/Dfe.Academies.Domain/Establishment/GovernanceRoleType.cs new file mode 100644 index 000000000..ba622303c --- /dev/null +++ b/Dfe.Academies.Domain/Establishment/GovernanceRoleType.cs @@ -0,0 +1,11 @@ +namespace Dfe.Academies.Domain.Establishment +{ + public class GovernanceRoleType + { + public long SK { get; set; } + public string Name { get; set; } + public DateTime? Modified { get; set; } + public string? ModifiedBy { get; set; } + } + +} diff --git a/Dfe.Academies.Domain/Establishment/IEstablishmentRepository.cs b/Dfe.Academies.Domain/Establishment/IEstablishmentRepository.cs deleted file mode 100644 index b7729bf03..000000000 --- a/Dfe.Academies.Domain/Establishment/IEstablishmentRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Dfe.Academies.Domain.Establishment -{ - public interface IEstablishmentRepository - { - Task GetEstablishmentByUkprn(string ukprn, CancellationToken cancellationToken); - Task GetEstablishmentByUrn(string urn, CancellationToken cancellationToken); - Task> Search(string name, string ukPrn, - string urn, CancellationToken cancellationToken); - Task> GetURNsByRegion(string[] regions, CancellationToken cancellationToken); - Task> GetByTrust(long? trustId, CancellationToken cancellationToken); - Task> GetByUrns(int[] Urns, CancellationToken cancellationToken); - Task> GetByUkprns(string[] Urns, CancellationToken cancellationToken); - } -} diff --git a/Dfe.Academies.Domain/Establishment/LocalAuthority.cs b/Dfe.Academies.Domain/Establishment/LocalAuthority.cs index e0cfeda6c..7654c29dd 100644 --- a/Dfe.Academies.Domain/Establishment/LocalAuthority.cs +++ b/Dfe.Academies.Domain/Establishment/LocalAuthority.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Dfe.Academies.Domain.Establishment +namespace Dfe.Academies.Domain.Establishment { public class LocalAuthority { diff --git a/Dfe.Academies.Domain/Interfaces/Caching/ICacheService.cs b/Dfe.Academies.Domain/Interfaces/Caching/ICacheService.cs new file mode 100644 index 000000000..45509b850 --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Caching/ICacheService.cs @@ -0,0 +1,8 @@ +namespace Dfe.Academies.Domain.Interfaces.Caching +{ + public interface ICacheService + { + Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName); + void Remove(string cacheKey); + } +} diff --git a/Dfe.Academies.Domain/Census/ICensusDataRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/ICensusDataRepository.cs similarity index 55% rename from Dfe.Academies.Domain/Census/ICensusDataRepository.cs rename to Dfe.Academies.Domain/Interfaces/Repositories/ICensusDataRepository.cs index de9c3445d..90bf2b327 100644 --- a/Dfe.Academies.Domain/Census/ICensusDataRepository.cs +++ b/Dfe.Academies.Domain/Interfaces/Repositories/ICensusDataRepository.cs @@ -1,4 +1,6 @@ -namespace Dfe.Academies.Domain.Census +using Dfe.Academies.Domain.Census; + +namespace Dfe.Academies.Domain.Interfaces.Repositories { public interface ICensusDataRepository { diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/IConstituencyRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IConstituencyRepository.cs new file mode 100644 index 000000000..45b3ca275 --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IConstituencyRepository.cs @@ -0,0 +1,11 @@ +using Dfe.Academies.Domain.Constituencies; + +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface IConstituencyRepository + { + Task GetMemberOfParliamentByConstituencyAsync(string constituencyName, CancellationToken cancellationToken); + IQueryable GetMembersOfParliamentByConstituenciesQueryable(List constituencyNames); + + } +} diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/IEducationalPerformanceRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IEducationalPerformanceRepository.cs new file mode 100644 index 000000000..84442b31d --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IEducationalPerformanceRepository.cs @@ -0,0 +1,9 @@ +using Dfe.Academies.Domain.EducationalPerformance; + +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface IEducationalPerformanceRepository + { + public Task> GetSchoolAbsencesByURN(string urn, CancellationToken cancellationToken); + } +} diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/IEstablishmentRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IEstablishmentRepository.cs new file mode 100644 index 000000000..43ad950ea --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IEstablishmentRepository.cs @@ -0,0 +1,14 @@ +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface IEstablishmentRepository + { + Task GetEstablishmentByUkprn(string ukprn, CancellationToken cancellationToken); + Task GetEstablishmentByUrn(string urn, CancellationToken cancellationToken); + Task> Search(string name, string ukPrn, + string urn, CancellationToken cancellationToken); + Task> GetURNsByRegion(string[] regions, CancellationToken cancellationToken); + Task> GetByTrust(long? trustId, CancellationToken cancellationToken); + Task> GetByUrns(int[] Urns, CancellationToken cancellationToken); + Task> GetByUkprns(string[] Urns, CancellationToken cancellationToken); + } +} diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/IMopRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IMopRepository.cs new file mode 100644 index 000000000..606902a14 --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IMopRepository.cs @@ -0,0 +1,6 @@ +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface IMopRepository : IRepository where TEntity : class, new() + { + } +} diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/IMstrRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IMstrRepository.cs new file mode 100644 index 000000000..7c12a4397 --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IMstrRepository.cs @@ -0,0 +1,6 @@ +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface IMstrRepository : IRepository where TEntity : class, new() + { + } +} diff --git a/Dfe.Academies.Domain/Repositories/IRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/IRepository.cs similarity index 99% rename from Dfe.Academies.Domain/Repositories/IRepository.cs rename to Dfe.Academies.Domain/Interfaces/Repositories/IRepository.cs index a46d8b6af..cfc7a2465 100644 --- a/Dfe.Academies.Domain/Repositories/IRepository.cs +++ b/Dfe.Academies.Domain/Interfaces/Repositories/IRepository.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace Dfe.Academies.Domain.Repositories +namespace Dfe.Academies.Domain.Interfaces.Repositories { /// Repository /// @@ -41,6 +41,14 @@ Task> FetchAsync( /// The entity found, or null. TEntity Find(params object[] keyValues); + /// + /// Returns the first entity of a sequence that satisfies a specified condition + /// or a default value if no such entity is found. + /// + /// A function to test an entity for a condition + /// The entity found, or null + TEntity Find(Expression> predicate); + /// /// Asynchronously finds an entity with the given primary key value. If an entity with the /// given primary key values exists in the context, then it is returned immediately @@ -53,14 +61,6 @@ Task> FetchAsync( /// The entity found, or null. Task FindAsync(params object[] keyValues); - /// - /// Returns the first entity of a sequence that satisfies a specified condition - /// or a default value if no such entity is found. - /// - /// A function to test an entity for a condition - /// The entity found, or null - TEntity Find(Expression> predicate); - /// /// Asynchronously returns the first entity of a sequence that satisfies a specified condition /// or a default value if no such entity is found. @@ -86,6 +86,16 @@ Task FindAsync( /// If no entity is found in the context or the store -or- more than one entity is found, then an TEntity Get(params object[] keyValues); + /// + /// Gets an entity that satisfies a specified condition, + /// and throws an exception if more than one such element exists. + /// + /// A function to test an element for a condition. + /// + /// No entity satisfies the condition in predicate. -or- More than one entity satisfies the condition in predicate. -or- The source sequence is empty. + /// + TEntity Get(Expression> predicate); + /// /// Asynchronously gets an entity with the given primary key value. If an entity with the /// given primary key values exists in the context, then it is returned immediately @@ -100,16 +110,6 @@ Task FindAsync( /// If no entity is found in the context or the store -or- more than one entity is found, then an Task GetAsync(params object[] keyValues); - /// - /// Gets an entity that satisfies a specified condition, - /// and throws an exception if more than one such element exists. - /// - /// A function to test an element for a condition. - /// - /// No entity satisfies the condition in predicate. -or- More than one entity satisfies the condition in predicate. -or- The source sequence is empty. - /// - TEntity Get(Expression> predicate); - /// /// Asynchronously gets an entity that satisfies a specified condition, /// and throws an exception if more than one such element exists. @@ -149,7 +149,6 @@ Task FindAsync( /// /// IEnumerable AddRange(ICollection entities); - /// /// diff --git a/Dfe.Academies.Domain/Interfaces/Repositories/ITrustRepository.cs b/Dfe.Academies.Domain/Interfaces/Repositories/ITrustRepository.cs new file mode 100644 index 000000000..79a070e7c --- /dev/null +++ b/Dfe.Academies.Domain/Interfaces/Repositories/ITrustRepository.cs @@ -0,0 +1,14 @@ +using Dfe.Academies.Domain.Trust; + +namespace Dfe.Academies.Domain.Interfaces.Repositories +{ + public interface ITrustRepository + { + Task GetTrustByUkprn(string ukprn, CancellationToken cancellationToken); + Task GetTrustByCompaniesHouseNumber(string companiesHouseNumber, CancellationToken cancellationToken); + Task GetTrustByTrustReferenceNumber(string trustReferenceNumber, CancellationToken cancellationToken); + Task> GetTrustsByUkprns(string[] ukprns, CancellationToken cancellationToken); + Task<(List, int)> Search(int page, int count, string? name, string? ukPrn, + string? companiesHouseNumber, TrustStatus status, CancellationToken cancellationToken); + } +} diff --git a/Dfe.Academies.Domain/Persons/Constituency.cs b/Dfe.Academies.Domain/Persons/Constituency.cs deleted file mode 100644 index 6435b6bb5..000000000 --- a/Dfe.Academies.Domain/Persons/Constituency.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Dfe.Academies.Domain.Persons -{ - public class Constituency - { - public int ConstituencyId { get; set; } - public int MemberID { get; set; } - public string ConstituencyName { get; set; } - public string NameList { get; set; } - public string NameDisplayAs { get; set; } - public string NameFullTitle { get; set; } - public DateTime LastRefresh { get; set; } - public DateOnly? EndDate { get; set; } - } -} diff --git a/Dfe.Academies.Domain/Persons/MemberContactDetails.cs b/Dfe.Academies.Domain/Persons/MemberContactDetails.cs deleted file mode 100644 index a60aff7ad..000000000 --- a/Dfe.Academies.Domain/Persons/MemberContactDetails.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Dfe.Academies.Domain.Persons -{ - public class MemberContactDetails - { - public int MemberID { get; set; } - public string Email { get; set; } - public int TypeId { get; set; } - } -} diff --git a/Dfe.Academies.Domain/Trust/ITrustRepository.cs b/Dfe.Academies.Domain/Trust/ITrustRepository.cs deleted file mode 100644 index 984c9c10b..000000000 --- a/Dfe.Academies.Domain/Trust/ITrustRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Dfe.Academies.Domain.Trust -{ - public interface ITrustRepository - { - Task GetTrustByUkprn(string ukprn, CancellationToken cancellationToken); - Task GetTrustByCompaniesHouseNumber(string companiesHouseNumber, CancellationToken cancellationToken); - Task GetTrustByTrustReferenceNumber(string trustReferenceNumber, CancellationToken cancellationToken); - Task> GetTrustsByUkprns(string[] ukprns, CancellationToken cancellationToken); - Task<(List, int)> Search(int page, int count, string? name, string? ukPrn, - string? companiesHouseNumber, TrustStatus status, CancellationToken cancellationToken); - } -} diff --git a/Dfe.Academies.Domain/ValueObjects/ConstituencyId.cs b/Dfe.Academies.Domain/ValueObjects/ConstituencyId.cs new file mode 100644 index 000000000..25e51fce7 --- /dev/null +++ b/Dfe.Academies.Domain/ValueObjects/ConstituencyId.cs @@ -0,0 +1,38 @@ +using Dfe.Academies.Domain.Common; + +namespace Dfe.Academies.Domain.ValueObjects +{ + public class ConstituencyId : ValueObject + { + public int Value { get; } + + public ConstituencyId(int value) + { + if (value <= 0) + { + throw new ArgumentException("Constituency ID must be a positive integer.", nameof(value)); + } + Value = value; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + public override string ToString() + { + return Value.ToString(); + } + + public static implicit operator int(ConstituencyId constituencyId) + { + return constituencyId.Value; + } + + public static implicit operator ConstituencyId(int value) + { + return new ConstituencyId(value); + } + } +} diff --git a/Dfe.Academies.Domain/ValueObjects/MemberId.cs b/Dfe.Academies.Domain/ValueObjects/MemberId.cs new file mode 100644 index 000000000..66d8e294f --- /dev/null +++ b/Dfe.Academies.Domain/ValueObjects/MemberId.cs @@ -0,0 +1,38 @@ +using Dfe.Academies.Domain.Common; + +namespace Dfe.Academies.Domain.ValueObjects +{ + public class MemberId : ValueObject + { + public int Value { get; } + + public MemberId(int value) + { + if (value <= 0) + { + throw new ArgumentException("Member ID must be a positive integer.", nameof(value)); + } + Value = value; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + public override string ToString() + { + return Value.ToString(); + } + + public static implicit operator int(MemberId memberId) + { + return memberId.Value; + } + + public static implicit operator MemberId(int value) + { + return new MemberId(value); + } + } +} diff --git a/Dfe.Academies.Domain/ValueObjects/NameDetails.cs b/Dfe.Academies.Domain/ValueObjects/NameDetails.cs new file mode 100644 index 000000000..475acf625 --- /dev/null +++ b/Dfe.Academies.Domain/ValueObjects/NameDetails.cs @@ -0,0 +1,29 @@ +using Dfe.Academies.Domain.Common; + +namespace Dfe.Academies.Domain.ValueObjects +{ + public class NameDetails : ValueObject + { + public string NameListAs { get; } + public string NameDisplayAs { get; } + public string NameFullTitle { get; } + + public NameDetails(string nameListAs, string nameDisplayAs, string nameFullTitle) + { + if (string.IsNullOrEmpty(nameListAs)) throw new ArgumentNullException(nameof(nameListAs)); + if (string.IsNullOrEmpty(nameDisplayAs)) throw new ArgumentNullException(nameof(nameDisplayAs)); + if (string.IsNullOrEmpty(nameFullTitle)) throw new ArgumentNullException(nameof(nameFullTitle)); + + NameListAs = nameListAs; + NameDisplayAs = nameDisplayAs; + NameFullTitle = nameFullTitle; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return NameListAs; + yield return NameDisplayAs; + yield return NameFullTitle; + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs b/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs deleted file mode 100644 index 2b053caa7..000000000 --- a/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Dfe.Academies.PersonsApi.Tests.Integration.Mocks; -using Microsoft.EntityFrameworkCore; -using PersonsApi; -using System.Net; - -namespace Dfe.Academies.PersonsApi.Tests.Integration.Controllers -{ - public class When_Fetching_Mp_By_Constituency : IClassFixture> - { - private readonly CustomWebApplicationFactory _factory; - - public When_Fetching_Mp_By_Constituency(CustomWebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task it_should_return_mp_when_constituency_exist() - { - var client = _factory.CreateClient(); - - var dbcontext = _factory.GetDbContext(); - - await dbcontext.Constituencies.Where(x => x.ConstituencyName == "Test Constituency 1") - .ExecuteUpdateAsync(x => x.SetProperty(p => p.ConstituencyName, "NewConstituencyName")); - - var constituencyName = Uri.EscapeDataString("NewConstituencyName"); - - var response = await client.GetAsync($"v1/Constituencies/{constituencyName}/mp"); - - await response.Content.ReadAsStringAsync(); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task it_should_return_notfound_when_constituency_doesnt_exist() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync($"v1/Constituencies/test/mp"); - - await response.Content.ReadAsStringAsync(); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - } -} diff --git a/Dfe.Academies.PersonsApi.Tests.Integration/Mocks/CustomWebApplicationFactory.cs b/Dfe.Academies.PersonsApi.Tests.Integration/Mocks/CustomWebApplicationFactory.cs deleted file mode 100644 index c4a7fa73f..000000000 --- a/Dfe.Academies.PersonsApi.Tests.Integration/Mocks/CustomWebApplicationFactory.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Dfe.Academies.Academisation.Data; -using Dfe.Academies.Domain.Persons; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Data.Common; - -namespace Dfe.Academies.PersonsApi.Tests.Integration.Mocks -{ - public class CustomWebApplicationFactory - : WebApplicationFactory where TProgram : class - { - private SqliteConnection? _connection; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureServices(services => - { - var dbContextDescriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(DbContextOptions)); - - services.Remove(dbContextDescriptor!); - - var dbConnectionDescriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(DbConnection)); - - services.Remove(dbConnectionDescriptor!); - - services.AddSingleton(container => - { - if (_connection == null) - { - _connection = new SqliteConnection("DataSource=:memory:"); - _connection.Open(); - } - - return _connection; - }); - - services.AddDbContext((container, options) => - { - var connection = container.GetRequiredService(); - options.UseSqlite(connection); - }); - - var serviceProvider = services.BuildServiceProvider(); - - using (var scope = serviceProvider.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - - db.Database.EnsureCreated(); - - SeedTestData(db); - } - }); - - builder.UseEnvironment("Development"); - } - - public MopContext GetDbContext() - { - var scopeFactory = Services.GetRequiredService(); - var scope = scopeFactory.CreateScope(); - return scope.ServiceProvider.GetRequiredService(); - } - - private static void SeedTestData(MopContext context) - { - context.MemberContactDetails.Add(new MemberContactDetails - { - MemberID = 1, - Email = "test1@example.com", - TypeId = 1 - }); - context.MemberContactDetails.Add(new MemberContactDetails - { - MemberID = 2, - Email = "test2@example.com", - TypeId = 2 - }); - - context.Constituencies.Add(new Constituency - { - ConstituencyId = 1, - ConstituencyName = "Test Constituency 1", - NameList = "Wood, John", - NameDisplayAs = "John Wood", - NameFullTitle = "John Wood MP", - LastRefresh = DateTime.UtcNow, - MemberID = 1 - }); - context.Constituencies.Add(new Constituency - { - ConstituencyId = 2, - ConstituencyName = "Test Constituency 2", - NameList = "Wood, Joe", - NameDisplayAs = "Joe Wood", - NameFullTitle = "Joe Wood MP", - LastRefresh = DateTime.UtcNow, - MemberID= 2 - }); - - context.SaveChanges(); - } - - protected override void ConfigureClient(HttpClient client) - { - client.DefaultRequestHeaders.Add("ApiKey", "app-key"); - - base.ConfigureClient(client); - } - } -} diff --git a/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs b/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs deleted file mode 100644 index de821079b..000000000 --- a/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Dfe.Academies.PersonsApi.Tests.Integration.Mocks; -using PersonsApi; -using System.Net; - -namespace Dfe.Academies.PersonsApi.Tests.Integration.OpenApiTests; - -public class OpenApiDocumentTests : IClassFixture> -{ - private readonly HttpClient _client; - - public OpenApiDocumentTests(CustomWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task SwaggerEndpoint_ReturnsSuccessAndCorrectContentType() - { - var response = await _client.GetAsync("/swagger/v1/swagger.json"); - - response.EnsureSuccessStatusCode(); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } -} \ No newline at end of file diff --git a/Dfe.Academies.Utils/Caching/CacheKeyHelper.cs b/Dfe.Academies.Utils/Caching/CacheKeyHelper.cs new file mode 100644 index 000000000..cab1a4a94 --- /dev/null +++ b/Dfe.Academies.Utils/Caching/CacheKeyHelper.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Dfe.Academies.Utils.Caching +{ + public static class CacheKeyHelper + { + /// + /// Generates a hashed cache key for any given input string. + /// + /// The input string to be hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentException("Input cannot be null or empty", nameof(input)); + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + /// + /// Generates a hashed cache key for a collection of strings by concatenating them. + /// + /// A collection of strings to be concatenated and hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(IEnumerable inputs) + { + if (inputs == null || !inputs.Any()) + { + throw new ArgumentException("Input collection cannot be null or empty", nameof(inputs)); + } + + var concatenatedInput = string.Join(",", inputs); + + return GenerateHashedCacheKey(concatenatedInput); + } + } + +} diff --git a/Dfe.PersonsApi.Client/Dfe.PersonsApi.Client.csproj b/Dfe.PersonsApi.Client/Dfe.PersonsApi.Client.csproj index 5127bfc36..afac01a2f 100644 --- a/Dfe.PersonsApi.Client/Dfe.PersonsApi.Client.csproj +++ b/Dfe.PersonsApi.Client/Dfe.PersonsApi.Client.csproj @@ -16,7 +16,7 @@ - + @@ -25,6 +25,7 @@ + diff --git a/Dfe.PersonsApi.Client/Extensions/ServiceCollectionExtensions.cs b/Dfe.PersonsApi.Client/Extensions/ServiceCollectionExtensions.cs index 97fedfb30..eba42247d 100644 --- a/Dfe.PersonsApi.Client/Extensions/ServiceCollectionExtensions.cs +++ b/Dfe.PersonsApi.Client/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Dfe.PersonsApi.Client.Security; using Dfe.PersonsApi.Client.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using System.Diagnostics.CodeAnalysis; namespace Dfe.PersonsApi.Client.Extensions @@ -8,23 +9,44 @@ namespace Dfe.PersonsApi.Client.Extensions [ExcludeFromCodeCoverage] public static class ServiceCollectionExtensions { - public static IServiceCollection AddPersonsApiClient(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddPersonsApiClient( + this IServiceCollection services, + IConfiguration configuration, + HttpClient? existingHttpClient = null) where TClientInterface : class where TClientImplementation : class, TClientInterface { var apiSettings = new PersonsApiClientSettings(); configuration.GetSection("PersonsApiClient").Bind(apiSettings); - services.AddHttpClient((httpClient, serviceProvider) => - { - httpClient.BaseAddress = new Uri(apiSettings.BaseUrl!); - httpClient.DefaultRequestHeaders.Add("ApiKey", apiSettings.ApiKey); + services.AddSingleton(apiSettings); + services.AddSingleton(); - return ActivatorUtilities.CreateInstance(serviceProvider, httpClient, apiSettings.BaseUrl!); - }); + if (existingHttpClient != null) + { + services.AddSingleton(existingHttpClient); + services.AddTransient(serviceProvider => + { + return ActivatorUtilities.CreateInstance( + serviceProvider, existingHttpClient, apiSettings.BaseUrl!); + }); + } + else + { + services.AddHttpClient((httpClient, serviceProvider) => + { + httpClient.BaseAddress = new Uri(apiSettings.BaseUrl!); + return ActivatorUtilities.CreateInstance( + serviceProvider, httpClient, apiSettings.BaseUrl!); + }) + .AddHttpMessageHandler(serviceProvider => + { + var tokenService = serviceProvider.GetRequiredService(); + return new BearerTokenHandler(tokenService); + }); + } return services; } } - } diff --git a/Dfe.PersonsApi.Client/Generated/Client.g.cs b/Dfe.PersonsApi.Client/Generated/Client.g.cs index f004e6287..60d36e3ea 100644 --- a/Dfe.PersonsApi.Client/Generated/Client.g.cs +++ b/Dfe.PersonsApi.Client/Generated/Client.g.cs @@ -175,6 +175,362 @@ public virtual async System.Threading.Tasks.Task GetMemberOf } } + /// + /// Retrieve a collection of Member of Parliament by a collection of constituency names + /// + /// The request. + /// A collection of MemberOfParliament objects. + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetMembersOfParliamentByConstituenciesAsync(GetMembersOfParliamentByConstituenciesQuery request) + { + return GetMembersOfParliamentByConstituenciesAsync(request, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieve a collection of Member of Parliament by a collection of constituency names + /// + /// The request. + /// A collection of MemberOfParliament objects. + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetMembersOfParliamentByConstituenciesAsync(GetMembersOfParliamentByConstituenciesQuery request, System.Threading.CancellationToken cancellationToken) + { + if (request == null) + throw new System.ArgumentNullException("request"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(request, JsonSerializerSettings); + var content_ = new System.Net.Http.StringContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "v1/Constituencies/mps" + urlBuilder_.Append("v1/Constituencies/mps"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new PersonsApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new PersonsApiException("Constituency names cannot be null or empty.", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new PersonsApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + try + { + var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new PersonsApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var streamReader = new System.IO.StreamReader(responseStream)) + using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader)) + { + var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings); + var typedBody = serializer.Deserialize(jsonTextReader); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new PersonsApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class EstablishmentsClient : IEstablishmentsClient + { + #pragma warning disable 8618 + private string _baseUrl; + #pragma warning restore 8618 + + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private Newtonsoft.Json.JsonSerializerSettings _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public EstablishmentsClient(string baseUrl, System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + BaseUrl = baseUrl; + _httpClient = httpClient; + Initialize(); + } + + private static Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings() + { + var settings = new Newtonsoft.Json.JsonSerializerSettings(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + public string BaseUrl + { + get { return _baseUrl; } + set + { + _baseUrl = value; + if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) + _baseUrl += '/'; + } + } + + protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// + /// Retrieve All Members Associated With an Academy by Urn + /// + /// The URN. + /// A Collection of Persons Associated With the Academy. + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetAllPersonsAssociatedWithAcademyByUrnAsync(int urn) + { + return GetAllPersonsAssociatedWithAcademyByUrnAsync(urn, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieve All Members Associated With an Academy by Urn + /// + /// The URN. + /// A Collection of Persons Associated With the Academy. + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetAllPersonsAssociatedWithAcademyByUrnAsync(int urn, System.Threading.CancellationToken cancellationToken) + { + if (urn == null) + throw new System.ArgumentNullException("urn"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "v1/Establishments/{urn}/getAssociatedPersons" + urlBuilder_.Append("v1/Establishments/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(urn, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/getAssociatedPersons"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new PersonsApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 404) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new PersonsApiException("Academy not found.", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new PersonsApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + protected struct ObjectResponseResult { public ObjectResponseResult(T responseObject, string responseText) diff --git a/Dfe.PersonsApi.Client/Generated/Contracts.g.cs b/Dfe.PersonsApi.Client/Generated/Contracts.g.cs index 206859688..5bdd5eb5d 100644 --- a/Dfe.PersonsApi.Client/Generated/Contracts.g.cs +++ b/Dfe.PersonsApi.Client/Generated/Contracts.g.cs @@ -42,10 +42,70 @@ public partial interface IConstituenciesClient /// A server side error occurred. System.Threading.Tasks.Task GetMemberOfParliamentByConstituencyAsync(string constituencyName, System.Threading.CancellationToken cancellationToken); + /// + /// Retrieve a collection of Member of Parliament by a collection of constituency names + /// + /// The request. + /// A collection of MemberOfParliament objects. + /// A server side error occurred. + System.Threading.Tasks.Task> GetMembersOfParliamentByConstituenciesAsync(GetMembersOfParliamentByConstituenciesQuery request); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieve a collection of Member of Parliament by a collection of constituency names + /// + /// The request. + /// A collection of MemberOfParliament objects. + /// A server side error occurred. + System.Threading.Tasks.Task> GetMembersOfParliamentByConstituenciesAsync(GetMembersOfParliamentByConstituenciesQuery request, System.Threading.CancellationToken cancellationToken); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IEstablishmentsClient + { + /// + /// Retrieve All Members Associated With an Academy by Urn + /// + /// The URN. + /// A Collection of Persons Associated With the Academy. + /// A server side error occurred. + System.Threading.Tasks.Task> GetAllPersonsAssociatedWithAcademyByUrnAsync(int urn); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieve All Members Associated With an Academy by Urn + /// + /// The URN. + /// A Collection of Persons Associated With the Academy. + /// A server side error occurred. + System.Threading.Tasks.Task> GetAllPersonsAssociatedWithAcademyByUrnAsync(int urn, System.Threading.CancellationToken cancellationToken); + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class MemberOfParliament : Person + { + [Newtonsoft.Json.JsonProperty("constituencyName", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string ConstituencyName { get; set; } + + public string ToJson() + { + + return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); + + } + public static MemberOfParliament FromJson(string data) + { + + return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); + + } + } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class MemberOfParliament + public partial class Person { [Newtonsoft.Json.JsonProperty("id", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int Id { get; set; } @@ -65,11 +125,14 @@ public partial class MemberOfParliament [Newtonsoft.Json.JsonProperty("displayNameWithTitle", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string DisplayNameWithTitle { get; set; } - [Newtonsoft.Json.JsonProperty("role", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] - public string Role { get; set; } + [Newtonsoft.Json.JsonProperty("phone", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string Phone { get; set; } - [Newtonsoft.Json.JsonProperty("constituencyName", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] - public string ConstituencyName { get; set; } + [Newtonsoft.Json.JsonProperty("roles", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public System.Collections.Generic.List Roles { get; set; } + + [Newtonsoft.Json.JsonProperty("updatedAt", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public System.DateTime? UpdatedAt { get; set; } public string ToJson() { @@ -77,10 +140,55 @@ public string ToJson() return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); } - public static MemberOfParliament FromJson(string data) + public static Person FromJson(string data) { - return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); + return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); + + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GetMembersOfParliamentByConstituenciesQuery + { + [Newtonsoft.Json.JsonProperty("constituencyNames", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public System.Collections.Generic.List ConstituencyNames { get; set; } + + public string ToJson() + { + + return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); + + } + public static GetMembersOfParliamentByConstituenciesQuery FromJson(string data) + { + + return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); + + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AcademyGovernance : Person + { + [Newtonsoft.Json.JsonProperty("ukprn", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string Ukprn { get; set; } + + [Newtonsoft.Json.JsonProperty("urn", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public int? Urn { get; set; } + + public string ToJson() + { + + return Newtonsoft.Json.JsonConvert.SerializeObject(this, new Newtonsoft.Json.JsonSerializerSettings()); + + } + public static AcademyGovernance FromJson(string data) + { + + return Newtonsoft.Json.JsonConvert.DeserializeObject(data, new Newtonsoft.Json.JsonSerializerSettings()); } diff --git a/Dfe.PersonsApi.Client/Generated/swagger.json b/Dfe.PersonsApi.Client/Generated/swagger.json index 52cfbadbc..ffe057444 100644 --- a/Dfe.PersonsApi.Client/Generated/swagger.json +++ b/Dfe.PersonsApi.Client/Generated/swagger.json @@ -44,11 +44,107 @@ } } } + }, + "/v1/Constituencies/mps": { + "post": { + "tags": [ + "Constituencies" + ], + "summary": "Retrieve a collection of Member of Parliament by a collection of constituency names", + "operationId": "Constituencies_GetMembersOfParliamentByConstituencies", + "requestBody": { + "x-name": "request", + "description": "The request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMembersOfParliamentByConstituenciesQuery" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "A collection of MemberOfParliament objects.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberOfParliament" + } + } + } + } + }, + "400": { + "description": "Constituency names cannot be null or empty." + } + } + } + }, + "/v1/Establishments/{urn}/getAssociatedPersons": { + "get": { + "tags": [ + "Establishments" + ], + "summary": "Retrieve All Members Associated With an Academy by Urn", + "operationId": "Establishments_GetAllPersonsAssociatedWithAcademyByUrn", + "parameters": [ + { + "name": "urn", + "in": "path", + "required": true, + "description": "The URN.", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "A Collection of Persons Associated With the Academy.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AcademyGovernance" + } + } + } + } + }, + "404": { + "description": "Academy not found." + } + } + } } }, "components": { "schemas": { "MemberOfParliament": { + "allOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "constituencyName": { + "type": "string" + } + } + } + ] + }, + "Person": { "type": "object", "additionalProperties": false, "properties": { @@ -71,13 +167,56 @@ "displayNameWithTitle": { "type": "string" }, - "role": { - "type": "string" + "phone": { + "type": "string", + "nullable": true }, - "constituencyName": { - "type": "string" + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true } } + }, + "GetMembersOfParliamentByConstituenciesQuery": { + "type": "object", + "additionalProperties": false, + "properties": { + "constituencyNames": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AcademyGovernance": { + "allOf": [ + { + "$ref": "#/components/schemas/Person" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "ukprn": { + "type": "string", + "nullable": true + }, + "urn": { + "type": "integer", + "format": "int32", + "nullable": true + } + } + } + ] } } } diff --git a/Dfe.PersonsApi.Client/Security/BearerTokenHandler.cs b/Dfe.PersonsApi.Client/Security/BearerTokenHandler.cs new file mode 100644 index 000000000..66633935c --- /dev/null +++ b/Dfe.PersonsApi.Client/Security/BearerTokenHandler.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; + +namespace Dfe.PersonsApi.Client.Security +{ + [ExcludeFromCodeCoverage] + public class BearerTokenHandler(ITokenAcquisitionService tokenAcquisitionService) : DelegatingHandler + { + protected override async Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + var token = await tokenAcquisitionService.GetTokenAsync(); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + return await base.SendAsync(request, cancellationToken); + } + } +} diff --git a/Dfe.PersonsApi.Client/Security/ITokenAcquisitionService.cs b/Dfe.PersonsApi.Client/Security/ITokenAcquisitionService.cs new file mode 100644 index 000000000..4d61acc1e --- /dev/null +++ b/Dfe.PersonsApi.Client/Security/ITokenAcquisitionService.cs @@ -0,0 +1,7 @@ +namespace Dfe.PersonsApi.Client.Security +{ + public interface ITokenAcquisitionService + { + Task GetTokenAsync(); + } +} diff --git a/Dfe.PersonsApi.Client/Security/TokenAcquisitionService.cs b/Dfe.PersonsApi.Client/Security/TokenAcquisitionService.cs new file mode 100644 index 000000000..4f248372e --- /dev/null +++ b/Dfe.PersonsApi.Client/Security/TokenAcquisitionService.cs @@ -0,0 +1,36 @@ +using Dfe.PersonsApi.Client.Settings; +using Microsoft.Identity.Client; +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.PersonsApi.Client.Security +{ + [ExcludeFromCodeCoverage] + public class TokenAcquisitionService : ITokenAcquisitionService + { + private readonly PersonsApiClientSettings _settings; + private readonly IConfidentialClientApplication _app; + private AuthenticationResult? _authResult; + + public TokenAcquisitionService(PersonsApiClientSettings settings) + { + _settings = settings; + + _app = ConfidentialClientApplicationBuilder.Create(_settings.ClientId) + .WithClientSecret(_settings.ClientSecret) + .WithAuthority(new Uri(_settings.Authority!)) + .Build(); + } + + public async Task GetTokenAsync() + { + // Check if the current token is about to expire + if (_authResult == null || _authResult.ExpiresOn <= DateTimeOffset.UtcNow.AddMinutes(-1)) + { + _authResult = await _app.AcquireTokenForClient(new[] { _settings.Scope }) + .ExecuteAsync(); + } + + return _authResult.AccessToken; + } + } +} diff --git a/Dfe.PersonsApi.Client/Settings/PersonsApiClientSettings.cs b/Dfe.PersonsApi.Client/Settings/PersonsApiClientSettings.cs index 05eecb70d..e160840b2 100644 --- a/Dfe.PersonsApi.Client/Settings/PersonsApiClientSettings.cs +++ b/Dfe.PersonsApi.Client/Settings/PersonsApiClientSettings.cs @@ -3,6 +3,9 @@ public class PersonsApiClientSettings { public string? BaseUrl { get; set; } - public string? ApiKey { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? Authority { get; set; } + public string? Scope { get; set; } } } diff --git a/Dfe.PersonsApi.Client/nswag.json b/Dfe.PersonsApi.Client/nswag.json index 3332dd394..9ef55b071 100644 --- a/Dfe.PersonsApi.Client/nswag.json +++ b/Dfe.PersonsApi.Client/nswag.json @@ -110,11 +110,11 @@ "dateTimeType": "System.DateTime", "timeType": "System.TimeSpan", "timeSpanType": "System.TimeSpan", - "arrayType": "System.Collections.ObjectModel.ObservableCollection", - "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "arrayType": "System.Collections.Generic.List", + "arrayInstanceType": "System.Collections.Generic.List", "dictionaryType": "System.Collections.Generic.Dictionary", "dictionaryInstanceType": "System.Collections.Generic.Dictionary", - "arrayBaseType": "System.Collections.ObjectModel.ObservableCollection", + "arrayBaseType": "System.Collections.Generic.List", "dictionaryBaseType": "System.Collections.Generic.Dictionary", "classStyle": "Poco", "generateDefaultValues": true, diff --git a/PersonsApi/Controllers/ConstituenciesController.cs b/PersonsApi/Controllers/ConstituenciesController.cs index 659cd66af..ca91fe67a 100644 --- a/PersonsApi/Controllers/ConstituenciesController.cs +++ b/PersonsApi/Controllers/ConstituenciesController.cs @@ -1,42 +1,48 @@ -using Dfe.Academies.Application.Models; -using Dfe.Academies.Application.Persons; +using Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituency; +using Dfe.Academies.Application.Common.Models; +using MediatR; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; +using Microsoft.AspNetCore.Authorization; +using Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituencies; namespace PersonsApi.Controllers { [ApiController] + [Authorize(Policy = "API.Read")] [ApiVersion("1.0")] [Route("v{version:apiVersion}/[controller]")] - public class ConstituenciesController : ControllerBase + public class ConstituenciesController(ISender sender) : ControllerBase { - private readonly IPersonsQueries _personQueries; - - public ConstituenciesController(IPersonsQueries personQueries) - { - _personQueries = personQueries; - } - /// /// Retrieve Member of Parliament by constituency name /// /// The constituency name. /// The cancellation token. - /// [HttpGet("{constituencyName}/mp")] [SwaggerResponse(200, "A Person object representing the Member of Parliament.", typeof(MemberOfParliament))] [SwaggerResponse(404, "Constituency not found.")] [SwaggerResponse(400, "Constituency cannot be null or empty.")] public async Task GetMemberOfParliamentByConstituencyAsync([FromRoute] string constituencyName, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(constituencyName)) - { - return BadRequest(); - } - - var result = await _personQueries.GetMemberOfParliamentByConstituencyAsync(constituencyName, cancellationToken); + var result = await sender.Send(new GetMemberOfParliamentByConstituencyQuery(constituencyName), cancellationToken); return result is null ? NotFound() : Ok(result); } + + /// + /// Retrieve a collection of Member of Parliament by a collection of constituency names + /// + /// The request. + /// The cancellation token. + [HttpPost("mps")] + [SwaggerResponse(200, "A collection of MemberOfParliament objects.", typeof(IEnumerable))] + [SwaggerResponse(400, "Constituency names cannot be null or empty.")] + public async Task GetMembersOfParliamentByConstituenciesAsync([FromBody] GetMembersOfParliamentByConstituenciesQuery request, CancellationToken cancellationToken) + { + var result = await sender.Send(request, cancellationToken); + + return Ok(result ?? []); + } } } diff --git a/PersonsApi/Controllers/EstablishmentsController.cs b/PersonsApi/Controllers/EstablishmentsController.cs new file mode 100644 index 000000000..979ae4962 --- /dev/null +++ b/PersonsApi/Controllers/EstablishmentsController.cs @@ -0,0 +1,32 @@ +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Application.Establishment.Queries.GetAllPersonsAssociatedWithAcademyByUrn; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace PersonsApi.Controllers +{ + [ApiController] + [Authorize(Policy = "API.Read")] + [ApiVersion("1.0")] + [Route("v{version:apiVersion}/[controller]")] + public class EstablishmentsController(ISender sender) : ControllerBase + { + /// + /// Retrieve All Members Associated With an Academy by Urn + /// + /// The URN. + /// The cancellation token. + /// + [HttpGet("{urn}/getAssociatedPersons")] + [SwaggerResponse(200, "A Collection of Persons Associated With the Academy.", typeof(List))] + [SwaggerResponse(404, "Academy not found.")] + public async Task GetAllPersonsAssociatedWithAcademyByUrnAsync([FromRoute] int urn, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetAllPersonsAssociatedWithAcademyByUrnQuery(urn), cancellationToken); + + return result is null ? NotFound() : Ok(result); + } + } +} diff --git a/PersonsApi/Extensions/ServiceCollectionExtensions.cs b/PersonsApi/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index 639b9ee47..000000000 --- a/PersonsApi/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using PersonsApi.UseCases; - -namespace Microsoft.Extensions.DependencyInjection -{ - public static class ServiceCollectionExtensions - { - public static IServiceCollection AddUseCases(this IServiceCollection services) - { - var allTypes = typeof(IUseCase<,>).Assembly.GetTypes(); - - foreach (var type in allTypes) - { - foreach (var @interface in type.GetInterfaces()) - { - if (@interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IUseCase<,>)) - { - services.AddScoped(@interface, type); - } - } - } - return services; - } - } -} diff --git a/PersonsApi/Middleware/ApiKeyMiddleware.cs b/PersonsApi/Middleware/ApiKeyMiddleware.cs deleted file mode 100644 index 2fff692d5..000000000 --- a/PersonsApi/Middleware/ApiKeyMiddleware.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace PersonsApi.Middleware -{ - using System; - using System.Threading.Tasks; - using Microsoft.AspNetCore.Http; - using Microsoft.Extensions.Logging; - using ResponseModels; - using UseCases; - - public class ApiKeyMiddleware - { - private readonly RequestDelegate _next; - private const string APIKEYNAME = "ApiKey"; - private readonly ILogger _logger; - - public ApiKeyMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - public async Task InvokeAsync(HttpContext context, IUseCase apiKeyService) - { - // Bypass API Key requirement for health check route - if (context.Request.Path == "/HealthCheck") { - await _next(context); - return; - } - - if (!context.Request.Headers.TryGetValue(APIKEYNAME, out var extractedApiKey)) - { - context.Response.StatusCode = 401; - await context.Response.WriteAsync("Api Key was not provided."); - return; - } - - var user = apiKeyService.Execute(extractedApiKey); - - if (user == null) - { - context.Response.StatusCode = 401; - await context.Response.WriteAsync("Unauthorized client."); - } - else - { - using (_logger.BeginScope("requester: {requester}", user.UserName)) - { - await _next(context); - } - } - } - } -} diff --git a/PersonsApi/Middleware/ExceptionHandlerMiddleware.cs b/PersonsApi/Middleware/ExceptionHandlerMiddleware.cs index 8cb07293b..e779c9291 100644 --- a/PersonsApi/Middleware/ExceptionHandlerMiddleware.cs +++ b/PersonsApi/Middleware/ExceptionHandlerMiddleware.cs @@ -1,37 +1,98 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using PersonsApi.ResponseModels; using System.Net; +using System.Text.Json; +using ValidationException = Dfe.Academies.Application.Common.Exceptions.ValidationException; -public class ExceptionHandlerMiddleware -{ - private readonly RequestDelegate _next; - public ExceptionHandlerMiddleware(RequestDelegate next) - { - _next = next; - } +namespace PersonsApi.Middleware; - public async Task InvokeAsync(HttpContext httpContext, ILogger logger) +public class ExceptionHandlerMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) { try { - await _next(httpContext); + await next(context); + + // Check for 401 or 403 status codes after the request has been processed + if (context.Response.StatusCode == (int)HttpStatusCode.Unauthorized) + { + await HandleUnauthorizedResponseAsync(context); + } + else if (context.Response.StatusCode == (int)HttpStatusCode.Forbidden) + { + await HandleForbiddenResponseAsync(context); + } + } + catch (ValidationException ex) + { + logger.LogError($"Validation error: {ex.Message}"); + await HandleValidationException(context, ex); } catch (Exception ex) { - logger.LogError($"Something went wrong: {ex}"); - await HandleExceptionAsync(httpContext, ex, logger); + logger.LogError("An exception occurred: {Message}", ex.Message); + logger.LogError("Stack Trace: {StackTrace}", ex.StackTrace); + + await HandleExceptionAsync(context, ex); } } - private async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger) + + // Handle validation exceptions + private async Task HandleValidationException(HttpContext httpContext, Exception ex) + { + var exception = (ValidationException)ex; + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors) + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }); + } + + // Handle 401 Unauthorized + private async Task HandleUnauthorizedResponseAsync(HttpContext context) { - - logger.LogError(exception.Message); - logger.LogError(exception.StackTrace); + logger.LogWarning("Unauthorized access attempt detected."); context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - await context.Response.WriteAsync(new ErrorResponse() + var errorResponse = new ErrorResponse + { + StatusCode = context.Response.StatusCode, + Message = "You are not authorized to access this resource." + }; + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse)); + } + + // Handle 403 Forbidden + private async Task HandleForbiddenResponseAsync(HttpContext context) + { + logger.LogWarning("Forbidden access attempt detected."); + context.Response.ContentType = "application/json"; + var errorResponse = new ErrorResponse + { + StatusCode = context.Response.StatusCode, + Message = "You do not have permission to access this resource." + }; + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse)); + } + + // Handle general exceptions (500 Internal Server Error) + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + var errorResponse = new ErrorResponse { StatusCode = context.Response.StatusCode, Message = "Internal Server Error: " + exception.Message - }.ToString()); + }; + + logger.LogError("Unhandled Exception: {Message}", exception.Message); + logger.LogError("Stack Trace: {StackTrace}", exception.StackTrace); + + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse)); } } \ No newline at end of file diff --git a/PersonsApi/Middleware/UrlDecoderMiddleware.cs b/PersonsApi/Middleware/UrlDecoderMiddleware.cs index d0bbe502d..1f2d336f9 100644 --- a/PersonsApi/Middleware/UrlDecoderMiddleware.cs +++ b/PersonsApi/Middleware/UrlDecoderMiddleware.cs @@ -4,15 +4,8 @@ namespace PersonsApi.Middleware { - public class UrlDecoderMiddleware + public class UrlDecoderMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; - - public UrlDecoderMiddleware(RequestDelegate next) - { - _next = next; - } - public async Task InvokeAsync(HttpContext context) { var queryString = context.Request.QueryString.ToString(); @@ -23,7 +16,7 @@ public async Task InvokeAsync(HttpContext context) var qb = new QueryBuilder(items); context.Request.QueryString = qb.ToQueryString(); - await _next(context); + await next(context); } } } \ No newline at end of file diff --git a/PersonsApi/PersonsApi.csproj b/PersonsApi/PersonsApi.csproj index b73f5da43..6b0f1f394 100644 --- a/PersonsApi/PersonsApi.csproj +++ b/PersonsApi/PersonsApi.csproj @@ -6,6 +6,7 @@ enable true $(NoWarn);1591 + 8c1ad605-0dd4-443a-ad18-dd22bbb2a9d9 @@ -41,20 +42,18 @@ + - - + + - - + + diff --git a/PersonsApi/Program.cs b/PersonsApi/Program.cs index aa4b8bfee..cfd31fa8b 100644 --- a/PersonsApi/Program.cs +++ b/PersonsApi/Program.cs @@ -1,6 +1,6 @@ +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Mvc.ApiExplorer; using PersonsApi; -using PersonsApi.SerilogCustomEnrichers; using Serilog; var builder = WebApplication.CreateBuilder(args); @@ -11,10 +11,14 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) => { - var enricher = services.GetRequiredService(); + loggerConfiguration + .ReadFrom.Configuration(context.Configuration) + .WriteTo.ApplicationInsights(services.GetRequiredService(), TelemetryConverter.Traces) + .Enrich.FromLogContext() + .WriteTo.Console(); }); -builder.Services.AddApplicationDependencyGroup(builder.Configuration); +builder.Services.AddPersonsApiApplicationDependencyGroup(builder.Configuration); var app = builder.Build(); diff --git a/PersonsApi/ResponseModels/ApiUser.cs b/PersonsApi/ResponseModels/ApiUser.cs deleted file mode 100644 index cf01edb0e..000000000 --- a/PersonsApi/ResponseModels/ApiUser.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PersonsApi.ResponseModels -{ - public class ApiUser - { - public string UserName { get; set; } - public string ApiKey { get; set; } - } -} \ No newline at end of file diff --git a/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs b/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs deleted file mode 100644 index af56d6cf6..000000000 --- a/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Serilog.Core; -using Serilog.Events; -using PersonsApi.ResponseModels; -using PersonsApi.UseCases; - -namespace PersonsApi.SerilogCustomEnrichers -{ - public class ApiUserEnricher : ILogEventEnricher - { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IUseCase _apiKeyService; - - public ApiUserEnricher(IHttpContextAccessor httpContextAccessor, IUseCase apiKeyService) - { - _httpContextAccessor = httpContextAccessor; - _apiKeyService = apiKeyService; - } - - public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) - { - var httpContext = _httpContextAccessor.HttpContext; - - if (httpContext is null) - { - return; - } - - ApiUser user = null; - - if (httpContext.Request.Headers.TryGetValue("ApiKey", out var apiKey)) - { - user = _apiKeyService.Execute(apiKey); - } - - var httpContextModel = new HttpContextModel - { - Method = httpContext.Request.Method, - User = user?.UserName ?? "Unknown or not applicable" - - }; - - logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ApiUser", httpContextModel.User, true)); - } - } - - public class HttpContextModel - { - public string Method { get; init; } - - public string User { get; init; } - } -} diff --git a/PersonsApi/Startup.cs b/PersonsApi/Startup.cs index 544e3fa2b..61d9681a9 100644 --- a/PersonsApi/Startup.cs +++ b/PersonsApi/Startup.cs @@ -1,38 +1,21 @@ +using Dfe.Academies.Application.MappingProfiles; using Dfe.Academisation.CorrelationIdMiddleware; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.FeatureManagement; +using NetEscapades.AspNetCore.SecurityHeaders; +using PersonsApi.Middleware; +using PersonsApi.Swagger; +using Swashbuckle.AspNetCore.SwaggerUI; +using System.Reflection; +using System.Text; using System.Text.Json.Serialization; namespace PersonsApi { - using Dfe.Academies.Application.MappingProfiles; - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Hosting; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc.ApiExplorer; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Hosting; - using Microsoft.FeatureManagement; - using Middleware; - using NetEscapades.AspNetCore.SecurityHeaders; - using PersonsApi.ResponseModels; - using PersonsApi.SerilogCustomEnrichers; - using PersonsApi.Swagger; - using Swashbuckle.AspNetCore.SwaggerUI; - using System; - using System.IO; - using System.Reflection; - using System.Text; - using UseCases; - - public class Startup + public class Startup(IConfiguration configuration) { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } + public IConfiguration Configuration { get; } = configuration; // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) @@ -41,6 +24,8 @@ public void ConfigureServices(IServiceCollection services) services.AddApiVersioning(); services.AddFeatureManagement(); + services.AddPersonsApiInfrastructureDependencyGroup(Configuration); + services.AddScoped(); services.AddApiVersioning(config => @@ -91,8 +76,6 @@ public void ConfigureServices(IServiceCollection services) }; }); - services.AddUseCases(); - var appInsightsCnnStr = Configuration?.GetSection("ApplicationInsights")?["ConnectionString"]; if (!string.IsNullOrWhiteSpace(appInsightsCnnStr)) { @@ -102,8 +85,6 @@ public void ConfigureServices(IServiceCollection services) }); } - services.AddSingleton, ApiKeyService>(); - services.AddSingleton(); services.AddHsts(options => { options.Preload = true; @@ -111,7 +92,7 @@ public void ConfigureServices(IServiceCollection services) options.MaxAge = TimeSpan.FromDays(365); }); - services.AddAutoMapper(typeof(PersonProfile)); + services.AddAutoMapper(typeof(ConstituencyProfile)); services.AddOpenApiDocument(configure => { @@ -178,7 +159,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers c.SwaggerEndpoint($"/swagger/{desc.GroupName}/swagger.json", desc.GroupName.ToUpperInvariant()); } - c.SupportedSubmitMethods(SubmitMethod.Get); + c.SupportedSubmitMethods(SubmitMethod.Get, SubmitMethod.Post, SubmitMethod.Put, SubmitMethod.Delete); }); if (env.IsDevelopment()) @@ -187,15 +168,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers } app.UseMiddleware(); - app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); app.UseHttpsRedirection(); app.UseRouting(); + + app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } } -} +} \ No newline at end of file diff --git a/PersonsApi/Swagger/AuthenticationHeaderOperationFilter.cs b/PersonsApi/Swagger/AuthenticationHeaderOperationFilter.cs index 756e57b63..e7ff8f295 100644 --- a/PersonsApi/Swagger/AuthenticationHeaderOperationFilter.cs +++ b/PersonsApi/Swagger/AuthenticationHeaderOperationFilter.cs @@ -8,13 +8,28 @@ public class AuthenticationHeaderOperationFilter : IOperationFilter public void Apply(OpenApiOperation operation, OperationFilterContext context) { operation.Security ??= new List(); - + var securityScheme = new OpenApiSecurityScheme { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "ApiKey" } + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Name = "Authorization", + Type = SecuritySchemeType.Http, + Description = "Input your Bearer token in this format - Bearer {your token here}", + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } }; - - operation.Security.Add(new OpenApiSecurityRequirement {{ securityScheme, new List() }}); + + operation.Security.Add(new OpenApiSecurityRequirement + { + { + securityScheme, new List() + } + }); } } } \ No newline at end of file diff --git a/PersonsApi/Swagger/SwaggerOptions.cs b/PersonsApi/Swagger/SwaggerOptions.cs index 154e80572..7e89bb9ac 100644 --- a/PersonsApi/Swagger/SwaggerOptions.cs +++ b/PersonsApi/Swagger/SwaggerOptions.cs @@ -10,18 +10,9 @@ public class SwaggerOptions : IConfigureNamedOptions { private readonly IApiVersionDescriptionProvider _provider; - private const string ApiKeyName = "ApiKey"; private const string ServiceTitle = "Persons API"; - private const string ServiceDescription = "The Persons API provides users with access to a variety of data " + - "regarding academies and trusts in England.\n\n" + - "The available data includes general data acadamy transfers and " + - "applications to become an academy and is compiled from a variety of internal and " + - "external services."; private const string ContactName = "Support"; private const string ContactEmail = "servicedelivery.rdd@education.gov.uk"; - - private const string SecuritySchemeDescription = "A valid ApiKey in the 'ApiKey' header is required to " + - "access the Academies API."; private const string DepreciatedMessage = "- API version has been depreciated."; public SwaggerOptions(IApiVersionDescriptionProvider provider) => _provider = provider; @@ -35,7 +26,6 @@ public void Configure(SwaggerGenOptions options) var openApiInfo = new OpenApiInfo { Title = ServiceTitle, - //TODO: EA Description = ServiceDescription, Contact = new OpenApiContact { Name = ContactName, @@ -47,15 +37,16 @@ public void Configure(SwaggerGenOptions options) options.SwaggerDoc(desc.GroupName, openApiInfo); } - - var securityScheme = new OpenApiSecurityScheme + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { - Name = ApiKeyName, - Description = SecuritySchemeDescription, - Type = SecuritySchemeType.ApiKey, - In = ParameterLocation.Header - }; - options.AddSecurityDefinition(ApiKeyName, securityScheme); + Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "Bearer" + }); + options.OperationFilter(); } } diff --git a/PersonsApi/UseCases/ApiKeyService.cs b/PersonsApi/UseCases/ApiKeyService.cs deleted file mode 100644 index e6d89d369..000000000 --- a/PersonsApi/UseCases/ApiKeyService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using PersonsApi.ResponseModels; - -namespace PersonsApi.UseCases -{ - public class ApiKeyService : IUseCase - { - private readonly IConfiguration _configuration; - - public ApiKeyService(IConfiguration configuration) - { - _configuration = configuration; - } - - public ApiUser Execute(string request) - { - - var keys = _configuration.GetSection("ApiKeys").Get>(); - - var key = keys?.FirstOrDefault(user => user.ApiKey.Equals(request)); - - return key; - } - } -} \ No newline at end of file diff --git a/PersonsApi/UseCases/IUseCase.cs b/PersonsApi/UseCases/IUseCase.cs deleted file mode 100644 index 89adec0eb..000000000 --- a/PersonsApi/UseCases/IUseCase.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PersonsApi.UseCases -{ - public interface IUseCase - { - TResponse Execute(TRequest request); - } -} diff --git a/PersonsApi/UseCases/IUseCaseAsync.cs b/PersonsApi/UseCases/IUseCaseAsync.cs deleted file mode 100644 index 8b1119d18..000000000 --- a/PersonsApi/UseCases/IUseCaseAsync.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace PersonsApi.UseCases -{ - /// - /// Represents a use case executor which accepts a request and returns a response. Enhances the standard IUseCase by supporting asynchronous execution. - /// - /// - /// - public interface IUseCaseAsync - { - Task Execute(TRequest request, CancellationToken cancellationToken); - } -} diff --git a/PersonsApi/appsettings.json b/PersonsApi/appsettings.json index fbd8796b2..4deea15f6 100644 --- a/PersonsApi/appsettings.json +++ b/PersonsApi/appsettings.json @@ -23,6 +23,13 @@ "ApiKey": "app-key" } ], + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "Domain": "platform.education.gov.uk", + "TenantId": "9c7d9dd3-840c-4b3f-818e-552865082e16", + "ClientId": "930a077f-43d0-48cb-9316-1e0430eeaf6b", + "Audience": "api://930a077f-43d0-48cb-9316-1e0430eeaf6b" + }, "ConnectionStrings": { "DefaultConnection": "Server=localhost,1433;Database=sip;User Id=sa;TrustServerCertificate=True;Password=StrongPassword905" }, @@ -44,5 +51,16 @@ } }, "FeatureManagement": { + }, + "CacheSettings": { + "DefaultDurationInSeconds": 60, + "Durations": { + "GetMemberOfParliamentByConstituencyAsync": 30 + } + }, + "Authorization": { + "Roles": [ + "API.Read" + ] } } diff --git a/Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj b/Tests/Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj similarity index 76% rename from Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj rename to Tests/Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj index ead32fc34..7210b9b4c 100644 --- a/Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj +++ b/Tests/Dfe.Academies.Application.Tests/Dfe.Academies.Application.Tests.csproj @@ -13,7 +13,8 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -25,7 +26,8 @@ - + + diff --git a/Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs b/Tests/Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs similarity index 98% rename from Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs rename to Tests/Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs index 0d16fa3e7..ee7b0bfe4 100644 --- a/Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs +++ b/Tests/Dfe.Academies.Application.Tests/Queries/EducationalPerformance/EducationalPerformanceQueriesTests.cs @@ -2,11 +2,9 @@ using Dfe.Academies.Application.EducationalPerformance; using Dfe.Academies.Contracts.V1.EducationalPerformance; using Dfe.Academies.Domain.EducationalPerformance; +using Dfe.Academies.Domain.Interfaces.Repositories; using FluentAssertions; using Moq; -using System; -using System.Threading.Tasks; -using Xunit; namespace Dfe.Academies.Application.Tests.Queries.EducationalPerformance { diff --git a/Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs b/Tests/Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs similarity index 97% rename from Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs rename to Tests/Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs index 4ac7b1439..911b2cd58 100644 --- a/Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs +++ b/Tests/Dfe.Academies.Application.Tests/Queries/Establishment/EstablishmentQueriesTests.cs @@ -1,9 +1,7 @@ using AutoFixture; using Dfe.Academies.Application.Establishment; using Dfe.Academies.Contracts.V4.Establishments; -using Dfe.Academies.Domain.Census; -using Dfe.Academies.Domain.Establishment; -using Dfe.Academies.Domain.Trust; +using Dfe.Academies.Domain.Interfaces.Repositories; using FluentAssertions; using Moq; using System.Globalization; @@ -18,6 +16,13 @@ public class EstablishmentQueriesTests public EstablishmentQueriesTests() { _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); } diff --git a/Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs b/Tests/Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs similarity index 99% rename from Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs rename to Tests/Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs index e304fb961..16f71047b 100644 --- a/Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs +++ b/Tests/Dfe.Academies.Application.Tests/Queries/Trust/TrustQueriesTests.cs @@ -4,6 +4,7 @@ using Dfe.Academies.Domain.Trust; using FluentAssertions; using Moq; +using Dfe.Academies.Domain.Interfaces.Repositories; namespace Dfe.Academies.Application.Tests.Queries.Trust { diff --git a/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentByConstituencyQueryHandlerTests.cs b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentByConstituencyQueryHandlerTests.cs new file mode 100644 index 000000000..e6764a182 --- /dev/null +++ b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentByConstituencyQueryHandlerTests.cs @@ -0,0 +1,50 @@ +using AutoFixture.Xunit2; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituency; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Customizations.Entities; +using Dfe.Academies.Testing.Common.Customizations.Models; +using Dfe.Academies.Utils.Caching; +using NSubstitute; + +namespace Dfe.Academies.Application.Tests.QueryHandlers.Constituency +{ + public class GetMemberOfParliamentByConstituencyQueryHandlerTests + { + [Theory] + [CustomAutoData( + typeof(MemberOfParliamentCustomization), + typeof(ConstituencyCustomization), + typeof(AutoMapperCustomization))] + public async Task Handle_ShouldReturnMemberOfParliament_WhenConstituencyExists( + [Frozen] IConstituencyRepository mockConstituencyRepository, + [Frozen] ICacheService mockCacheService, + GetMemberOfParliamentByConstituencyQueryHandler handler, + GetMemberOfParliamentByConstituencyQuery query, + Domain.Constituencies.Constituency constituency, + MemberOfParliament expectedMp) + { + // Arrange + var cacheKey = $"MemberOfParliament_{CacheKeyHelper.GenerateHashedCacheKey(query.ConstituencyName)}"; + mockConstituencyRepository.GetMemberOfParliamentByConstituencyAsync(query.ConstituencyName, default) + .Returns(constituency); + + mockCacheService.GetOrAddAsync(cacheKey, Arg.Any>>(), Arg.Any()) + .Returns(expectedMp); + + // Act + var result = await handler.Handle(query, default); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedMp.FirstName, result.FirstName); + Assert.Equal(expectedMp.LastName, result.LastName); + Assert.Equal(expectedMp.ConstituencyName, result.ConstituencyName); + + await mockCacheService.Received(1).GetOrAddAsync(cacheKey, Arg.Any>>(), nameof(GetMemberOfParliamentByConstituencyQueryHandler)); + } + } +} diff --git a/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentsByConstituenciesQueryHandlerTests.cs b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentsByConstituenciesQueryHandlerTests.cs new file mode 100644 index 000000000..c8c36e6d9 --- /dev/null +++ b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Constituency/GetMemberOfParliamentsByConstituenciesQueryHandlerTests.cs @@ -0,0 +1,56 @@ +using AutoFixture.Xunit2; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Application.Constituencies.Queries.GetMemberOfParliamentByConstituencies; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Domain.Interfaces.Repositories; +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Customizations.Entities; +using Dfe.Academies.Testing.Common.Customizations.Models; +using Dfe.Academies.Utils.Caching; +using NSubstitute; + + +namespace Dfe.Academies.Application.Tests.QueryHandlers.Constituency +{ + public class GetMemberOfParliamentByConstituenciesQueryHandlerTests + { + [Theory] + [CustomAutoData( + typeof(MemberOfParliamentCustomization), + typeof(ConstituencyCustomization), + typeof(AutoMapperCustomization))] + public async Task Handle_ShouldReturnMemberOfParliament_WhenConstituencyExists( + [Frozen] IConstituencyRepository mockConstituencyRepository, + [Frozen] ICacheService mockCacheService, + GetMembersOfParliamentByConstituenciesQueryHandler handler, + GetMembersOfParliamentByConstituenciesQuery query, + List constituencies, + List expectedMps) + { + // Arrange + var cacheKey = $"MemberOfParliament_{CacheKeyHelper.GenerateHashedCacheKey(query.ConstituencyNames)}"; + + mockConstituencyRepository.GetMembersOfParliamentByConstituenciesQueryable(query.ConstituencyNames) + .Returns(constituencies.AsQueryable()); + + mockCacheService.GetOrAddAsync(cacheKey, Arg.Any>>>(), Arg.Any()) + .Returns(expectedMps); + + // Act + var result = await handler.Handle(query, default); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedMps.Count, result.Count); + for (int i = 0; i < result.Count; i++) + { + Assert.Equal(expectedMps[i].FirstName, result[i].FirstName); + Assert.Equal(expectedMps[i].LastName, result[i].LastName); + Assert.Equal(expectedMps[i].ConstituencyName, result[i].ConstituencyName); + } + + await mockCacheService.Received(1).GetOrAddAsync(cacheKey, Arg.Any>>>(), nameof(GetMembersOfParliamentByConstituenciesQueryHandler)); + } + } +} diff --git a/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Establishment/GetAllPersonsAssociatedWithAcademyByUrnTests.cs b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Establishment/GetAllPersonsAssociatedWithAcademyByUrnTests.cs new file mode 100644 index 000000000..2d9d4d168 --- /dev/null +++ b/Tests/Dfe.Academies.Application.Tests/QueryHandlers/Establishment/GetAllPersonsAssociatedWithAcademyByUrnTests.cs @@ -0,0 +1,54 @@ +using AutoFixture.Xunit2; +using Dfe.Academies.Application.Common.Interfaces; +using Dfe.Academies.Application.Common.Models; +using Dfe.Academies.Application.Establishment.Queries.GetAllPersonsAssociatedWithAcademyByUrn; +using Dfe.Academies.Domain.Interfaces.Caching; +using Dfe.Academies.Infrastructure.Models; +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Customizations.Models; +using Dfe.Academies.Utils.Caching; +using NSubstitute; + +namespace Dfe.Academies.Application.Tests.QueryHandlers.Establishment +{ + public class GetAllPersonsAssociatedWithAcademyByUrnQueryHandlerTests + { + [Theory] + [CustomAutoData( + typeof(AcademyGovernanceCustomization), + typeof(AcademyGovernanceQueryModelCustomization), + typeof(AutoMapperCustomization))] + public async Task Handle_ShouldReturnPersonsAssociatedWithAcademy_WhenUrnExists( + [Frozen] IEstablishmentQueryService mockEstablishmentQueryService, + [Frozen] ICacheService mockCacheService, + GetAllPersonsAssociatedWithAcademyByUrnQueryHandler handler, + GetAllPersonsAssociatedWithAcademyByUrnQuery query, + List expectedGovernances, + IQueryable governanceQueryModels) + { + // Arrange + var cacheKey = $"PersonsAssociatedWithAcademy_{CacheKeyHelper.GenerateHashedCacheKey(query.Urn.ToString())}"; + + mockEstablishmentQueryService.GetPersonsAssociatedWithAcademyByUrn(query.Urn) + .Returns(governanceQueryModels); + + mockCacheService.GetOrAddAsync(cacheKey, Arg.Any?>>>(), Arg.Any()) + .Returns(expectedGovernances); + + // Act + var result = await handler.Handle(query, default); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedGovernances.Count, result.Count); + for (int i = 0; i < result.Count; i++) + { + Assert.Equal(expectedGovernances[i].FirstName, result[i].FirstName); + Assert.Equal(expectedGovernances[i].LastName, result[i].LastName); + } + + await mockCacheService.Received(1).GetOrAddAsync(cacheKey, Arg.Any?>>>(), nameof(GetAllPersonsAssociatedWithAcademyByUrnQueryHandler)); + } + } +} diff --git a/Dfe.Academies.Application.Tests/Usings.cs b/Tests/Dfe.Academies.Application.Tests/Usings.cs similarity index 100% rename from Dfe.Academies.Application.Tests/Usings.cs rename to Tests/Dfe.Academies.Application.Tests/Usings.cs diff --git a/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs new file mode 100644 index 000000000..6265c4fbc --- /dev/null +++ b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/ConstituenciesControllerTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using Dfe.Academies.Infrastructure; +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Mocks; +using Dfe.PersonsApi.Client.Contracts; +using Microsoft.EntityFrameworkCore; +using PersonsApi; +using System.Security.Claims; + +namespace Dfe.Academies.PersonsApi.Tests.Integration.Controllers +{ + public class ConstituenciesControllerTests + { + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetMemberOfParliamentByConstituencyAsync_ShouldReturnMp_WhenConstituencyExists( + CustomWebApplicationDbContextFactory factory, + IConstituenciesClient constituenciesClient) + { + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + // Arrange + var dbContext = factory.GetDbContext(); + + await dbContext.Constituencies + .Where(x => x.ConstituencyName == "Test Constituency 1") + .ExecuteUpdateAsync(x => x.SetProperty(p => p.ConstituencyName, "NewConstituencyName")); + + var constituencyName = Uri.EscapeDataString("NewConstituencyName"); + + // Act + var result = await constituenciesClient.GetMemberOfParliamentByConstituencyAsync(constituencyName); + + // Assert + Assert.NotNull(result); + Assert.Equal("NewConstituencyName", result.ConstituencyName); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetMemberOfParliamentByConstituencyAsync_ShouldReturnNotFound_WhenConstituencyDoesNotExist( + CustomWebApplicationDbContextFactory factory, + IConstituenciesClient constituenciesClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + var constituencyName = Uri.EscapeDataString("NonExistentConstituency"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await constituenciesClient.GetMemberOfParliamentByConstituencyAsync(constituencyName)); + + Assert.Equal(HttpStatusCode.NotFound, (HttpStatusCode)exception.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetMemberOfParliamentByConstituenciesAsync_ShouldReturnMps_WhenConstituenciesExists( + CustomWebApplicationDbContextFactory factory, + IConstituenciesClient constituenciesClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + var dbcontext = factory.GetDbContext(); + + await dbcontext.Constituencies.Where(x => x.ConstituencyName == "Test Constituency 1") + .ExecuteUpdateAsync(x => x.SetProperty(p => p.ConstituencyName, "NewConstituencyName")); + + var constituencyName = Uri.EscapeDataString("NewConstituencyName"); + + // Act + var result = await constituenciesClient.GetMembersOfParliamentByConstituenciesAsync( + new GetMembersOfParliamentByConstituenciesQuery() { ConstituencyNames = [constituencyName, "Test Constituency 2"] }); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(2, result.Count); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetMemberOfParliamentByConstituenciesAsync_ShouldReturnEmpty_WhenConstituenciesDontExists( + CustomWebApplicationDbContextFactory factory, + IConstituenciesClient constituenciesClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + // Act + var result = await constituenciesClient.GetMembersOfParliamentByConstituenciesAsync( + new GetMembersOfParliamentByConstituenciesQuery() { ConstituencyNames = ["constituencyName"] }); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetMemberOfParliamentByConstituenciesAsync_ShouldThrowAnException_WhenConstituenciesNotProvided( + CustomWebApplicationDbContextFactory factory, + IConstituenciesClient constituenciesClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await constituenciesClient.GetMembersOfParliamentByConstituenciesAsync( + new GetMembersOfParliamentByConstituenciesQuery() { ConstituencyNames = [] })); + + Assert.Equal(HttpStatusCode.BadRequest, (HttpStatusCode)exception.StatusCode); + } + } +} diff --git a/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/EstablishmentsControllerTests.cs b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/EstablishmentsControllerTests.cs new file mode 100644 index 000000000..7ff76c2c7 --- /dev/null +++ b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Controllers/EstablishmentsControllerTests.cs @@ -0,0 +1,78 @@ +using Dfe.Academies.Infrastructure; +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Mocks; +using Dfe.PersonsApi.Client.Contracts; +using Microsoft.EntityFrameworkCore; +using PersonsApi; +using System.Security.Claims; + +namespace Dfe.Academies.PersonsApi.Tests.Integration.Controllers +{ + public class EstablishmentsControllerTests + { + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetAllPersonsAssociatedWithAcademyAsync_ShouldReturnPeople_WhenAcademyExists( + CustomWebApplicationDbContextFactory factory, + IEstablishmentsClient establishmentsClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + var dbContext = factory.GetDbContext(); + + await dbContext.Establishments.Where(x => x.SK == 1) + .ExecuteUpdateAsync(x => x.SetProperty(p => p.URN, 22)); + + // Act + var result = await establishmentsClient.GetAllPersonsAssociatedWithAcademyByUrnAsync(22); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(2, result.Count()); + Assert.True(result.All(x => x.Roles.Any())); + Assert.Contains(result, x => x.FirstName == "Anna"); + Assert.Contains(result, x => x.FirstName == "John"); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetAllPersonsAssociatedWithAcademyAsync_ShouldReturnEmptyList_WhenAcademyExistWithNoPeople( + CustomWebApplicationDbContextFactory factory, + IEstablishmentsClient establishmentsClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + var dbContext = factory.GetDbContext(); + + await dbContext.Establishments.Where(x => x.SK == 2) + .ExecuteUpdateAsync(x => x.SetProperty(p => p.URN, 33)); + + // Act + var result = await establishmentsClient.GetAllPersonsAssociatedWithAcademyByUrnAsync(33); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetAllPersonsAssociatedWithAcademyAsync_ShouldThrowAnException_WhenAcademyDoesntExists( + CustomWebApplicationDbContextFactory factory, + IEstablishmentsClient establishmentsClient) + { + // Arrange + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + establishmentsClient.GetAllPersonsAssociatedWithAcademyByUrnAsync(1)); + + Assert.Contains("Academy not found.", exception.Message); + } + } +} \ No newline at end of file diff --git a/Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj similarity index 74% rename from Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj rename to Tests/Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj index aca5500c4..03619e456 100644 --- a/Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj +++ b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/Dfe.Academies.PersonsApi.Tests.Integration.csproj @@ -22,8 +22,10 @@ - - + + + + diff --git a/Tests/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs new file mode 100644 index 000000000..708b1b0db --- /dev/null +++ b/Tests/Dfe.Academies.PersonsApi.Tests.Integration/OpenApiTests/OpenApiDocumentTests.cs @@ -0,0 +1,23 @@ +using Dfe.Academies.Testing.Common.Attributes; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Mocks; +using PersonsApi; +using System.Net; + +namespace Dfe.Academies.PersonsApi.Tests.Integration.OpenApiTests; + +public class OpenApiDocumentTests +{ + [Theory] + [CustomAutoData(typeof(CustomWebApplicationFactoryCustomization))] + public async Task SwaggerEndpoint_ReturnsSuccessAndCorrectContentType( + CustomWebApplicationFactory factory, + HttpClient client) + { + var response = await client.GetAsync("/swagger/v1/swagger.json"); + + response.EnsureSuccessStatusCode(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/Tests/Dfe.Academies.Testing.Common/Attributes/CustomAutoDataAttribute.cs b/Tests/Dfe.Academies.Testing.Common/Attributes/CustomAutoDataAttribute.cs new file mode 100644 index 000000000..219cd844c --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Attributes/CustomAutoDataAttribute.cs @@ -0,0 +1,16 @@ +using AutoFixture.Xunit2; +using Dfe.Academies.Testing.Common.Customizations; +using Dfe.Academies.Testing.Common.Helpers; + +namespace Dfe.Academies.Testing.Common.Attributes +{ + public class CustomAutoDataAttribute(params Type[] customizations) + : AutoDataAttribute(() => FixtureFactoryHelper.ConfigureFixtureFactory(CombineCustomizations(customizations))) + { + private static Type[] CombineCustomizations(Type[] customizations) + { + var defaultCustomizations = new[] { typeof(NSubstituteCustomization) }; + return defaultCustomizations.Concat(customizations).ToArray(); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Attributes/InlineCustomAutoDataAttribute.cs b/Tests/Dfe.Academies.Testing.Common/Attributes/InlineCustomAutoDataAttribute.cs new file mode 100644 index 000000000..6bb07a922 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Attributes/InlineCustomAutoDataAttribute.cs @@ -0,0 +1,7 @@ +using AutoFixture.Xunit2; + +namespace Dfe.Academies.Testing.Common.Attributes +{ + public class InlineCustomAutoDataAttribute(object[] values, params Type[] customizations) + : InlineAutoDataAttribute(new CustomAutoDataAttribute(customizations), values); +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/AutoMapperCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/AutoMapperCustomization.cs new file mode 100644 index 000000000..5903a5db9 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/AutoMapperCustomization.cs @@ -0,0 +1,31 @@ +using AutoFixture; +using AutoMapper; +using System.Reflection; + +namespace Dfe.Academies.Testing.Common.Customizations +{ + public class AutoMapperCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer.FromFactory(() => + { + var profiles = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => typeof(Profile).IsAssignableFrom(t) && !t.IsAbstract) + .ToList(); + + var config = new MapperConfiguration(cfg => + { + foreach (var profileType in profiles) + { + var profileInstance = (Profile)Activator.CreateInstance(profileType)!; + cfg.AddProfile(profileInstance); + } + }); + + return config.CreateMapper(); + })); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationDbContextFactoryCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationDbContextFactoryCustomization.cs new file mode 100644 index 000000000..796b6d3a4 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationDbContextFactoryCustomization.cs @@ -0,0 +1,56 @@ +using AutoFixture; +using Dfe.Academies.Testing.Common.Mocks; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Dfe.PersonsApi.Client; +using Dfe.PersonsApi.Client.Contracts; +using Dfe.PersonsApi.Client.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dfe.Academies.Testing.Common.Customizations +{ + public class CustomWebApplicationDbContextFactoryCustomization : ICustomization + where TProgram : class where TDbContext : DbContext + { + private readonly List _testClaims; + + public void Customize(IFixture fixture) + { + fixture.Customize>(composer => composer.FromFactory(() => + { + + var factory = new CustomWebApplicationDbContextFactory() + { + TestClaims = _testClaims + }; + + var client = factory.CreateClient(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "PersonsApiClient:BaseUrl", client.BaseAddress!.ToString() } + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddPersonsApiClient(config, client); + services.AddPersonsApiClient(config, client); + + var serviceProvider = services.BuildServiceProvider(); + + fixture.Inject(factory); + fixture.Inject(serviceProvider); + fixture.Inject(client); + fixture.Inject(serviceProvider.GetRequiredService()); + fixture.Inject(serviceProvider.GetRequiredService()); + + return factory; + })); + + fixture.Inject(_testClaims); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationFactoryCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationFactoryCustomization.cs new file mode 100644 index 000000000..50533c204 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/CustomWebApplicationFactoryCustomization.cs @@ -0,0 +1,51 @@ +using AutoFixture; +using Dfe.Academies.Testing.Common.Mocks; +using Dfe.PersonsApi.Client; +using Dfe.PersonsApi.Client.Contracts; +using Dfe.PersonsApi.Client.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Security.Claims; + +namespace Dfe.Academies.Testing.Common.Customizations +{ + public class CustomWebApplicationFactoryCustomization : ICustomization + where TProgram : class { + private readonly List _testClaims; + + public void Customize(IFixture fixture) + { + fixture.Customize>(composer => composer.FromFactory(() => + { + + var factory = new CustomWebApplicationFactory(_testClaims); + + var client = factory.CreateClient(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "PersonsApiClient:BaseUrl", client.BaseAddress!.ToString() } + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddPersonsApiClient(config, client); + services.AddPersonsApiClient(config, client); + + var serviceProvider = services.BuildServiceProvider(); + + fixture.Inject(factory); + fixture.Inject(serviceProvider); + fixture.Inject(client); + fixture.Inject(serviceProvider.GetRequiredService()); + fixture.Inject(serviceProvider.GetRequiredService()); + + return factory; + })); + + fixture.Inject(_testClaims); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/DbContextCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/DbContextCustomization.cs new file mode 100644 index 000000000..a0d5e2258 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/DbContextCustomization.cs @@ -0,0 +1,26 @@ +using AutoFixture; +using Dfe.Academies.Testing.Common.Helpers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Dfe.Academies.Testing.Common.Customizations +{ + public class DbContextCustomization : ICustomization where TContext : DbContext + { + private SqliteConnection? _connection; + + public void Customize(IFixture fixture) + { + fixture.Register>(() => null); + + fixture.Customize(composer => composer.FromFactory(() => + { + var services = new ServiceCollection(); + var dbContext = DbContextHelper.CreateDbContext(services); + fixture.Inject(dbContext); + return dbContext; + }).OmitAutoProperties()); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/Entities/ConstituencyCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/Entities/ConstituencyCustomization.cs new file mode 100644 index 000000000..fa3d59cbe --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/Entities/ConstituencyCustomization.cs @@ -0,0 +1,33 @@ +using AutoFixture; +using Dfe.Academies.Domain.Constituencies; +using Dfe.Academies.Domain.ValueObjects; + +namespace Dfe.Academies.Testing.Common.Customizations.Entities +{ + public class ConstituencyCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer.FromFactory(() => + { + var constituencyId = fixture.Create(); + var memberId = fixture.Create(); + var nameDetails = new NameDetails( + "Doe, John", + "John Doe", + "Mr. John Doe MP" + ); + + return new Constituency( + constituencyId, + memberId, + fixture.Create(), + nameDetails, + fixture.Create(), + DateOnly.FromDateTime(fixture.Create().Date), + fixture.Create() + ); + })); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceCustomization.cs new file mode 100644 index 000000000..1e7c1229b --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceCustomization.cs @@ -0,0 +1,20 @@ +using AutoFixture; +using Dfe.Academies.Application.Common.Models; + +namespace Dfe.Academies.Testing.Common.Customizations.Models +{ + public class AcademyGovernanceCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(x => x.FirstName, "John") + .With(x => x.LastName, "Doe") + .With(x => x.Email, "john.doe@example.com") + .With(x => x.DisplayName, "John Doe") + .With(x => x.DisplayNameWithTitle, "Mr. John Doe") + .With(x => x.Roles, new List { "MP" }) + .With(x => x.UpdatedAt, DateTime.Now)); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceQueryModelCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceQueryModelCustomization.cs new file mode 100644 index 000000000..861a451a4 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/AcademyGovernanceQueryModelCustomization.cs @@ -0,0 +1,22 @@ +using AutoFixture; +using Dfe.Academies.Domain.Establishment; +using Dfe.Academies.Infrastructure.Models; + +namespace Dfe.Academies.Testing.Common.Customizations.Models +{ + public class AcademyGovernanceQueryModelCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .FromFactory(() => + { + var establishmentGovernance = fixture.Create(); + var governanceRoleType = fixture.Create(); + var establishment = fixture.Create(); + + return new AcademyGovernanceQueryModel(establishmentGovernance, governanceRoleType, establishment); + })); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/Models/MemberOfParliamentCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/MemberOfParliamentCustomization.cs new file mode 100644 index 000000000..0165fd301 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/Models/MemberOfParliamentCustomization.cs @@ -0,0 +1,21 @@ +using AutoFixture; +using Dfe.Academies.Application.Common.Models; + +namespace Dfe.Academies.Testing.Common.Customizations.Models +{ + public class MemberOfParliamentCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(x => x.ConstituencyName, "ExampleConstituency") + .With(x => x.FirstName, "John") + .With(x => x.LastName, "Doe") + .With(x => x.Email, "john.doe@example.com") + .With(x => x.DisplayName, "John Doe") + .With(x => x.DisplayNameWithTitle, "Mr. John Doe") + .With(x => x.Roles, new List { "MP" }) + .With(x => x.UpdatedAt, DateTime.Now)); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Customizations/NSubstituteCustomization.cs b/Tests/Dfe.Academies.Testing.Common/Customizations/NSubstituteCustomization.cs new file mode 100644 index 000000000..4d272eac2 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Customizations/NSubstituteCustomization.cs @@ -0,0 +1,13 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; + +namespace Dfe.Academies.Testing.Common.Customizations +{ + public class NSubstituteCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(new AutoNSubstituteCustomization()); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Dfe.Academies.Testing.Common.csproj b/Tests/Dfe.Academies.Testing.Common/Dfe.Academies.Testing.Common.csproj new file mode 100644 index 000000000..b79498a03 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Dfe.Academies.Testing.Common.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/Dfe.Academies.Testing.Common/Helpers/DbContextHelper.cs b/Tests/Dfe.Academies.Testing.Common/Helpers/DbContextHelper.cs new file mode 100644 index 000000000..987f9eee7 --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Helpers/DbContextHelper.cs @@ -0,0 +1,183 @@ +using Dfe.Academies.Domain.Constituencies; +using Dfe.Academies.Domain.Establishment; +using Dfe.Academies.Domain.Trust; +using Dfe.Academies.Domain.ValueObjects; +using Dfe.Academies.Infrastructure; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Data.Common; + +namespace Dfe.Academies.Testing.Common.Helpers +{ + public static class DbContextHelper where TContext : DbContext + { + private static SqliteConnection? _connection; + + public static TContext CreateDbContext(IServiceCollection services) + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + services.AddSingleton(_ => connection); + + services.AddDbContext((sp, options) => + { + var conn = sp.GetRequiredService(); + options.UseSqlite(conn); + }); + + var serviceProvider = services.BuildServiceProvider(); + var dbContext = serviceProvider.GetRequiredService(); + + dbContext.Database.EnsureCreated(); + SeedTestData(dbContext); + + return dbContext; + } + + private static void SeedTestData(TContext context) + { + if (context is MstrContext mstrContext) + { + // Populate Trust + var trust1 = new Trust { SK = 1, Name = "Trust A", TrustTypeId = mstrContext.TrustTypes.FirstOrDefault()?.SK, GroupUID = "G1", Modified = DateTime.UtcNow, ModifiedBy = "System" }; + var trust2 = new Trust { SK = 2, Name = "Trust B", TrustTypeId = mstrContext.TrustTypes.FirstOrDefault()?.SK, GroupUID = "G2", Modified = DateTime.UtcNow, ModifiedBy = "System" }; + mstrContext.Trusts.AddRange(trust1, trust2); + + // Populate Establishment + var establishment1 = new Establishment + { + SK = 1, + EstablishmentName = "School A", + LocalAuthorityId = mstrContext.LocalAuthorities.FirstOrDefault()?.SK, + EstablishmentTypeId = mstrContext.EstablishmentTypes.FirstOrDefault()?.SK, + Latitude = 54.9784, + Longitude = -1.6174, + MainPhone = "01234567890", + Email = "schoolA@example.com", + Modified = DateTime.UtcNow, + ModifiedBy = "System" + }; + var establishment2 = new Establishment + { + SK = 2, + EstablishmentName = "School B", + LocalAuthorityId = mstrContext.LocalAuthorities.FirstOrDefault()?.SK, + EstablishmentTypeId = mstrContext.EstablishmentTypes.FirstOrDefault()?.SK, + Latitude = 50.3763, + Longitude = -4.1427, + MainPhone = "09876543210", + Email = "schoolB@example.com", + Modified = DateTime.UtcNow, + ModifiedBy = "System" + }; + mstrContext.Establishments.AddRange(establishment1, establishment2); + + // Populate EducationEstablishmentTrust + var educationEstablishmentTrust1 = new EducationEstablishmentTrust + { + SK = 1, + EducationEstablishmentId = (int)establishment1.SK, + TrustId = (int)trust1.SK, + }; + var educationEstablishmentTrust2 = new EducationEstablishmentTrust + { + SK = 2, + EducationEstablishmentId = (int)establishment2.SK, + TrustId = (int)trust2.SK, + + }; + mstrContext.EducationEstablishmentTrusts.AddRange(educationEstablishmentTrust1, educationEstablishmentTrust2); + + // Populate GovernanceRoleType + var governanceRoleType1 = new GovernanceRoleType { SK = 1, Name = "Chair of Governors", Modified = DateTime.UtcNow, ModifiedBy = "System" }; + var governanceRoleType2 = new GovernanceRoleType { SK = 2, Name = "Vice Chair of Governors", Modified = DateTime.UtcNow, ModifiedBy = "System" }; + mstrContext.GovernanceRoleTypes.AddRange(governanceRoleType1, governanceRoleType2); + + // Populate EducationEstablishmentGovernance + var governance1 = new EducationEstablishmentGovernance + { + SK = 1, + EducationEstablishmentId = establishment1.SK, + GovernanceRoleTypeId = governanceRoleType1.SK, + GID = "GID1", + Title = "Mr.", + Forename1 = "John", + Surname = "Doe", + Email = "johndoe@example.com", + Modified = DateTime.UtcNow, + ModifiedBy = "System" + }; + var governance3 = new EducationEstablishmentGovernance + { + SK = 3, + EducationEstablishmentId = establishment1.SK, + GovernanceRoleTypeId = governanceRoleType2.SK, + GID = "GID2", + Title = "Ms.", + Forename1 = "Anna", + Surname = "Smith", + Email = "annasmith@example.com", + Modified = DateTime.UtcNow, + ModifiedBy = "System" + }; + mstrContext.EducationEstablishmentGovernances.AddRange(governance1, governance3); + + // Save changes + mstrContext.SaveChanges(); + } + + if (context is MopContext mopContext) + { + var memberContact1 = new MemberContactDetails( + new MemberId(1), + 1, + "test1@example.com", + null + ); + + var memberContact2 = new MemberContactDetails( + new MemberId(2), + 1, + "test2@example.com", + null + ); + + var constituency1 = new Constituency( + new ConstituencyId(1), + new MemberId(1), + "Test Constituency 1", + new NameDetails( + "Wood, John", + "John Wood", + "Mr. John Wood MP" + ), + DateTime.UtcNow, + null, + memberContact1 + ); + + var constituency2 = new Constituency( + new ConstituencyId(2), + new MemberId(2), + "Test Constituency 2", + new NameDetails( + "Wood, Joe", + "Joe Wood", + "Mr. Joe Wood MP" + ), + DateTime.UtcNow, + null, + memberContact2 + ); + + mopContext.Constituencies.Add(constituency1); + mopContext.Constituencies.Add(constituency2); + + mopContext.SaveChanges(); + } + } + + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Helpers/FixtureFactoryHelper.cs b/Tests/Dfe.Academies.Testing.Common/Helpers/FixtureFactoryHelper.cs new file mode 100644 index 000000000..165f282fa --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Helpers/FixtureFactoryHelper.cs @@ -0,0 +1,20 @@ +using AutoFixture; + +namespace Dfe.Academies.Testing.Common.Helpers +{ + public static class FixtureFactoryHelper + { + public static IFixture ConfigureFixtureFactory(Type[] customizations) + { + var fixture = new Fixture(); + + foreach (var customizationType in customizations) + { + var customization = (ICustomization)Activator.CreateInstance(customizationType)!; + fixture.Customize(customization); + } + + return fixture; + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationDbContextFactory.cs b/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationDbContextFactory.cs new file mode 100644 index 000000000..73a1c906c --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationDbContextFactory.cs @@ -0,0 +1,63 @@ +using Dfe.Academies.PersonsApi.Tests.Integration.Mocks; +using Dfe.Academies.Testing.Common.Helpers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Data.Common; +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace Dfe.Academies.Testing.Common.Mocks +{ + public class CustomWebApplicationDbContextFactory : WebApplicationFactory + where TProgram : class where TDbContext : DbContext + { + public List TestClaims { get; set; } = []; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + services.Remove(dbContextDescriptor!); + + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbConnection)); + services.Remove(dbConnectionDescriptor!); + + DbContextHelper.CreateDbContext(services); + + services.PostConfigure(options => + { + options.DefaultAuthenticateScheme = "TestScheme"; + options.DefaultChallengeScheme = "TestScheme"; + }); + + services.AddAuthentication("TestScheme") + .AddScheme("TestScheme", options => { }); + + services.AddSingleton>(sp => TestClaims); + }); + + builder.UseEnvironment("Development"); + } + + protected override void ConfigureClient(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "mock-token"); + + base.ConfigureClient(client); + } + + public TDbContext GetDbContext() + { + var scopeFactory = Services.GetRequiredService(); + var scope = scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationFactory.cs b/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationFactory.cs new file mode 100644 index 000000000..cd108b99e --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Mocks/CustomWebApplicationFactory.cs @@ -0,0 +1,43 @@ +using Dfe.Academies.PersonsApi.Tests.Integration.Mocks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace Dfe.Academies.Testing.Common.Mocks +{ + public class CustomWebApplicationFactory(List testClaims) : WebApplicationFactory + where TProgram : class + { + + public List TestClaims { get; set; } = testClaims ?? new List(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.PostConfigure(options => + { + options.DefaultAuthenticateScheme = "TestScheme"; + options.DefaultChallengeScheme = "TestScheme"; + }); + + services.AddAuthentication("TestScheme") + .AddScheme("TestScheme", options => { }); + + services.AddSingleton>(sp => TestClaims); + }); + + builder.UseEnvironment("Development"); + } + + protected override void ConfigureClient(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "mock-token"); + + base.ConfigureClient(client); + } + } +} diff --git a/Tests/Dfe.Academies.Testing.Common/Mocks/MockJwtBearerHandler.cs b/Tests/Dfe.Academies.Testing.Common/Mocks/MockJwtBearerHandler.cs new file mode 100644 index 000000000..55c4a0b0a --- /dev/null +++ b/Tests/Dfe.Academies.Testing.Common/Mocks/MockJwtBearerHandler.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace Dfe.Academies.PersonsApi.Tests.Integration.Mocks +{ + public class MockJwtBearerHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + IEnumerable claims) + : AuthenticationHandler(options, logger, encoder, clock) + { + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity(claims, "mock"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "mock"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/TramsDataApi.sln b/TramsDataApi.sln index 8e88d9eaf..3359e0c82 100644 --- a/TramsDataApi.sln +++ b/TramsDataApi.sln @@ -16,18 +16,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.Domain", "Dfe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.Application", "Dfe.Academies.Application\Dfe.Academies.Application.csproj", "{40B09D94-75C2-46CA-9ACF-DCF40A8B2559}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{B3CDCA56-9765-4C2B-A4A4-2738C5A268EB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.Application.Tests", "Dfe.Academies.Application.Tests\Dfe.Academies.Application.Tests.csproj", "{AF49D395-A01A-43E9-A643-6B2266BA62C5}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.Utils", "Dfe.Academies.Utils\Dfe.Academies.Utils.csproj", "{F91012BF-C96E-499E-8C0F-B4D5235C6C3A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PersonsApi", "PersonsApi\PersonsApi.csproj", "{039FE264-0819-409A-8E01-53CA082812B3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.Academies.PersonsApi.Tests.Integration", "Dfe.Academies.PersonsApi.Tests.Integration\Dfe.Academies.PersonsApi.Tests.Integration.csproj", "{693F82DB-BD57-48C0-B558-783153354DA8}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PersonsApi.Client", "Dfe.PersonsApi.Client\Dfe.PersonsApi.Client.csproj", "{7F1E6980-E412-409E-945E-6C4739F5D525}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{08B2EC51-B7D3-4D6E-BF99-C2ADC957F387}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.Application.Tests", "Tests\Dfe.Academies.Application.Tests\Dfe.Academies.Application.Tests.csproj", "{9FDF7C25-083F-4404-BE64-82EF26EED072}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.Academies.PersonsApi.Tests.Integration", "Tests\Dfe.Academies.PersonsApi.Tests.Integration\Dfe.Academies.PersonsApi.Tests.Integration.csproj", "{EB0D20C1-4818-44DF-97A7-276C9F96CC64}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.Academies.Testing.Common", "Tests\Dfe.Academies.Testing.Common\Dfe.Academies.Testing.Common.csproj", "{777C300F-FBB1-402A-A850-1D26417FA412}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,10 +52,6 @@ Global {40B09D94-75C2-46CA-9ACF-DCF40A8B2559}.Debug|Any CPU.Build.0 = Debug|Any CPU {40B09D94-75C2-46CA-9ACF-DCF40A8B2559}.Release|Any CPU.ActiveCfg = Release|Any CPU {40B09D94-75C2-46CA-9ACF-DCF40A8B2559}.Release|Any CPU.Build.0 = Release|Any CPU - {AF49D395-A01A-43E9-A643-6B2266BA62C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AF49D395-A01A-43E9-A643-6B2266BA62C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AF49D395-A01A-43E9-A643-6B2266BA62C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AF49D395-A01A-43E9-A643-6B2266BA62C5}.Release|Any CPU.Build.0 = Release|Any CPU {F91012BF-C96E-499E-8C0F-B4D5235C6C3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F91012BF-C96E-499E-8C0F-B4D5235C6C3A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F91012BF-C96E-499E-8C0F-B4D5235C6C3A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -62,21 +60,30 @@ Global {039FE264-0819-409A-8E01-53CA082812B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {039FE264-0819-409A-8E01-53CA082812B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {039FE264-0819-409A-8E01-53CA082812B3}.Release|Any CPU.Build.0 = Release|Any CPU - {693F82DB-BD57-48C0-B558-783153354DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {693F82DB-BD57-48C0-B558-783153354DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {693F82DB-BD57-48C0-B558-783153354DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {693F82DB-BD57-48C0-B558-783153354DA8}.Release|Any CPU.Build.0 = Release|Any CPU {7F1E6980-E412-409E-945E-6C4739F5D525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7F1E6980-E412-409E-945E-6C4739F5D525}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F1E6980-E412-409E-945E-6C4739F5D525}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F1E6980-E412-409E-945E-6C4739F5D525}.Release|Any CPU.Build.0 = Release|Any CPU + {9FDF7C25-083F-4404-BE64-82EF26EED072}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FDF7C25-083F-4404-BE64-82EF26EED072}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FDF7C25-083F-4404-BE64-82EF26EED072}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FDF7C25-083F-4404-BE64-82EF26EED072}.Release|Any CPU.Build.0 = Release|Any CPU + {EB0D20C1-4818-44DF-97A7-276C9F96CC64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB0D20C1-4818-44DF-97A7-276C9F96CC64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB0D20C1-4818-44DF-97A7-276C9F96CC64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB0D20C1-4818-44DF-97A7-276C9F96CC64}.Release|Any CPU.Build.0 = Release|Any CPU + {777C300F-FBB1-402A-A850-1D26417FA412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {777C300F-FBB1-402A-A850-1D26417FA412}.Debug|Any CPU.Build.0 = Debug|Any CPU + {777C300F-FBB1-402A-A850-1D26417FA412}.Release|Any CPU.ActiveCfg = Release|Any CPU + {777C300F-FBB1-402A-A850-1D26417FA412}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {AF49D395-A01A-43E9-A643-6B2266BA62C5} = {B3CDCA56-9765-4C2B-A4A4-2738C5A268EB} - {693F82DB-BD57-48C0-B558-783153354DA8} = {B3CDCA56-9765-4C2B-A4A4-2738C5A268EB} + {9FDF7C25-083F-4404-BE64-82EF26EED072} = {08B2EC51-B7D3-4D6E-BF99-C2ADC957F387} + {EB0D20C1-4818-44DF-97A7-276C9F96CC64} = {08B2EC51-B7D3-4D6E-BF99-C2ADC957F387} + {777C300F-FBB1-402A-A850-1D26417FA412} = {08B2EC51-B7D3-4D6E-BF99-C2ADC957F387} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F0704299-A9C2-448A-B816-E5BCCB345AF8} diff --git a/TramsDataApi/Startup.cs b/TramsDataApi/Startup.cs index 317c8f66b..f4da1f08b 100644 --- a/TramsDataApi/Startup.cs +++ b/TramsDataApi/Startup.cs @@ -1,34 +1,34 @@ -using System.Text.Json.Serialization; using Dfe.Academisation.CorrelationIdMiddleware; using Microsoft.AspNetCore.HttpOverrides; +using System.Text.Json.Serialization; namespace TramsDataApi { using DatabaseModels; + using Dfe.Academies.Application.MappingProfiles; using Gateways; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Microsoft.FeatureManagement; using Middleware; + using NetEscapades.AspNetCore.SecurityHeaders; using Swashbuckle.AspNetCore.SwaggerUI; using System; using System.IO; using System.Reflection; + using System.Text; using TramsDataApi.Configuration; using TramsDataApi.ResponseModels; using TramsDataApi.SerilogCustomEnrichers; - using UseCases; - using Microsoft.FeatureManagement; using TramsDataApi.Services; - using Microsoft.AspNetCore.Http; - using System.Text; - using NetEscapades.AspNetCore.SecurityHeaders; using TramsDataApi.Swagger; - using Dfe.Academies.Application.MappingProfiles; + using UseCases; public class Startup { @@ -84,6 +84,8 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + services.AddInfrastructureDependencyGroup(Configuration); + services.AddApiVersioning(config => { config.DefaultApiVersion = new Microsoft.AspNetCore.Mvc.ApiVersion(1, 0); @@ -144,7 +146,7 @@ public void ConfigureServices(IServiceCollection services) }); } - services.AddAutoMapper(typeof(PersonProfile)); + services.AddAutoMapper(typeof(ConstituencyProfile)); services.AddSingleton, ApiKeyService>(); services.AddSingleton(); diff --git a/TramsDataApi/TramsDataApi.csproj b/TramsDataApi/TramsDataApi.csproj index d7c7da002..c3dfae228 100644 --- a/TramsDataApi/TramsDataApi.csproj +++ b/TramsDataApi/TramsDataApi.csproj @@ -66,6 +66,7 @@ +