diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index 1534c962b..ce95f1f6e 100644 --- a/.github/workflows/build-and-push-image.yml +++ b/.github/workflows/build-and-push-image.yml @@ -49,18 +49,35 @@ jobs: name: Deploy '${{ needs.set-env.outputs.branch }}' to ${{ needs.set-env.outputs.environment }} needs: [ set-env ] uses: DFE-Digital/deploy-azure-container-apps-action/.github/workflows/build-push-deploy.yml@v2.2.0 + strategy: + matrix: + image: [ + "Dockerfile", + "Dockerfile.PersonsApi" + ] + include: + - image: "Dockerfile" + aca_name_secret: "AZURE_ACA_NAME" + prefix: "" + name: "tramsapi-app" + - image: "Dockerfile.PersonsApi" + aca_name_secret: "AZURE_PERSONS_API_ACA_NAME" + prefix: "persons-api-" + name: "personsapi-app" with: - docker-image-name: 'tramsapi-app' - docker-build-file-name: './Dockerfile' + docker-image-name: '${{ matrix.name }}' + docker-build-file-name: './${{ matrix.image }}' + docker-tag-prefix: ${{ matrix.prefix }} environment: ${{ needs.set-env.outputs.environment }} - annotate-release: true + # Only annotate the release once, because both apps are deployed at the same time + annotate-release: ${{ matrix.name == 'tramsapi-app' }} docker-build-args: | COMMIT_SHA="${{ needs.set-env.outputs.checked-out-sha }}" secrets: azure-acr-name: ${{ secrets.ACR_NAME }} azure-acr-credentials: ${{ secrets.ACR_CREDENTIALS }} azure-aca-credentials: ${{ secrets.AZURE_ACA_CREDENTIALS }} - azure-aca-name: ${{ secrets.AZURE_ACA_NAME }} + azure-aca-name: ${{ secrets[matrix.aca_name_secret] }} azure-aca-resource-group: ${{ secrets.AZURE_ACA_RESOURCE_GROUP }} create-tag: diff --git a/.github/workflows/continuous-integration-dotnet.yml b/.github/workflows/continuous-integration-dotnet.yml index 4429809bc..fe5af2b1b 100644 --- a/.github/workflows/continuous-integration-dotnet.yml +++ b/.github/workflows/continuous-integration-dotnet.yml @@ -7,6 +7,7 @@ on: paths: - 'Dfe.Academies.*/**' - 'TramsDataApi*/**' + - 'PersonsApi*/**' - '!Dfe.Academies.Performance/**' pull_request: branches: [ main ] @@ -14,6 +15,7 @@ on: paths: - 'Dfe.Academies.*/**' - 'TramsDataApi*/**' + - 'PersonsApi*/**' - '!Dfe.Academies.Performance/**' env: @@ -73,7 +75,7 @@ jobs: - name: Install dotnet reportgenerator run: dotnet tool install --global dotnet-reportgenerator-globaltool - + - name: Add nuget package source run: dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json" @@ -81,7 +83,7 @@ jobs: run: dotnet tool restore - name: Restore dependencies - run: dotnet restore TramsDataApi.sln + run: dotnet restore - name: Build, Test and Analyze env: @@ -97,4 +99,4 @@ jobs: - name: Stop containers if: always() - run: docker-compose -f "docker-compose.yml" down + run: docker compose -f "docker-compose.yml" down diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 35c09a140..16deefd1d 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,11 +4,18 @@ on: pull_request: paths: - Dockerfile + - Dockerfile.PersonsApi types: [opened, synchronize] jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + image: [ + "Dockerfile", + "Dockerfile.PersonsApi" + ] steps: - name: Checkout code uses: actions/checkout@v4 @@ -19,5 +26,6 @@ jobs: - name: Build docker image uses: docker/build-push-action@v6 with: + file: './${{ matrix.image }}' secrets: github_token=${{ secrets.GITHUB_TOKEN }} push: false diff --git a/CypressTests/cypress.env.json b/CypressTests/cypress.env.json new file mode 100644 index 000000000..b09f54849 --- /dev/null +++ b/CypressTests/cypress.env.json @@ -0,0 +1,7 @@ +{ + "url": "https://localhost", + "personsUrl": "https://localhost:7089", + "api": "https://localhost:7089", + "apiKey": "app-key", + "authKey": "app-key" +} \ No newline at end of file diff --git a/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj b/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj index 68cd9b939..aed6a2c15 100644 --- a/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj +++ b/Dfe.Academies.Api.Infrastructure/Dfe.Academies.Infrastructure.csproj @@ -22,7 +22,7 @@ - + diff --git a/Dfe.Academies.Api.Infrastructure/MopContext.cs b/Dfe.Academies.Api.Infrastructure/MopContext.cs new file mode 100644 index 000000000..78a4a4c80 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/MopContext.cs @@ -0,0 +1,64 @@ +using Dfe.Academies.Domain.Persons; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Dfe.Academies.Academisation.Data; + +public class MopContext : DbContext +{ + const string DEFAULT_SCHEMA = "mop"; + + public MopContext() + { + + } + + public MopContext(DbContextOptions options) : base(options) + { + + } + + public DbSet MemberContactDetails { get; set; } = null!; + public DbSet Constituencies { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + optionsBuilder.UseSqlServer("Server=localhost;Database=sip;Integrated Security=true;TrustServerCertificate=True"); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(ConfigureMemberContactDetails); + modelBuilder.Entity(ConfigureConstituency); + + base.OnModelCreating(modelBuilder); + } + + + private void ConfigureMemberContactDetails(EntityTypeBuilder memberContactDetailsConfiguration) + { + memberContactDetailsConfiguration.HasKey(e => e.MemberID); + + memberContactDetailsConfiguration.ToTable("MemberContactDetails", DEFAULT_SCHEMA); + memberContactDetailsConfiguration.Property(e => e.MemberID).HasColumnName("memberID"); + memberContactDetailsConfiguration.Property(e => e.Email).HasColumnName("email"); + 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.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.Property(e => e.LastRefresh).HasColumnName("lastRefresh"); + } + + +} diff --git a/Dfe.Academies.Api.Infrastructure/MopRepository.cs b/Dfe.Academies.Api.Infrastructure/MopRepository.cs new file mode 100644 index 000000000..3442a8faa --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/MopRepository.cs @@ -0,0 +1,12 @@ +using Dfe.Academies.Academisation.Data; +using Dfe.Academies.Infrastructure.Repositories; + +namespace Dfe.Academies.Infrastructure +{ + public class MopRepository : Repository where TEntity : class, new() + { + public MopRepository(MopContext dbContext) : base(dbContext) + { + } + } +} diff --git a/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs b/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs new file mode 100644 index 000000000..58ff568d4 --- /dev/null +++ b/Dfe.Academies.Api.Infrastructure/Repositories/Repository.cs @@ -0,0 +1,186 @@ +using Dfe.Academies.Domain.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Linq.Expressions; + +namespace Dfe.Academies.Infrastructure.Repositories +{ + public abstract class Repository : IRepository + where TEntity : class, new() + where TDbContext : DbContext + { + /// + /// The + /// + protected readonly TDbContext DbContext; + + /// Constructor + /// + protected Repository(TDbContext dbContext) => this.DbContext = dbContext; + + /// Short hand for _dbContext.Set + /// + protected virtual DbSet DbSet() + { + return this.DbContext.Set(); + } + + /// + public virtual IQueryable Query() => (IQueryable)this.DbSet(); + + /// + public virtual ICollection Fetch(Expression> predicate) + { + return (ICollection)((IQueryable)this.DbSet()).Where(predicate).ToList(); + } + + /// + public virtual async Task> FetchAsync( + Expression> predicate, + CancellationToken cancellationToken = default(CancellationToken)) + { + return (ICollection)await EntityFrameworkQueryableExtensions.ToListAsync(((IQueryable)this.DbSet()).Where(predicate), cancellationToken); + } + + /// + public virtual TEntity Find(params object[] keyValues) => this.DbSet().Find(keyValues); + + /// + public virtual async Task FindAsync(params object[] keyValues) + { + return await this.DbSet().FindAsync(keyValues); + } + + /// + public virtual TEntity Find(Expression> predicate) + { + return ((IQueryable)this.DbSet()).FirstOrDefault(predicate); + } + + /// + public virtual async Task FindAsync( + Expression> predicate, + CancellationToken cancellationToken = default(CancellationToken)) + { + return await EntityFrameworkQueryableExtensions.FirstOrDefaultAsync((IQueryable)this.DbSet(), predicate, cancellationToken); + } + + /// + 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)); + } + + /// + 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)); + } + + /// + public virtual TEntity Get(Expression> predicate) + { + return ((IQueryable)this.DbSet()).Single(predicate); + } + + /// + public virtual async Task GetAsync(Expression> predicate) + { + return await EntityFrameworkQueryableExtensions.SingleAsync((IQueryable)this.DbSet(), predicate, new CancellationToken()); + } + + /// + public virtual TEntity Add(TEntity entity) + { + this.DbContext.Add(entity); + this.DbContext.SaveChanges(); + return entity; + } + + /// + public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default(CancellationToken)) + { + EntityEntry entityEntry = await this.DbContext.AddAsync(entity, cancellationToken); + int num = await this.DbContext.SaveChangesAsync(cancellationToken); + return entity; + } + + /// + public virtual IEnumerable AddRange(ICollection entities) + { + this.DbContext.AddRange((IEnumerable)entities); + this.DbContext.SaveChanges(); + return (IEnumerable)entities; + } + + /// + public virtual async Task> AddRangeAsync( + ICollection entities, + CancellationToken cancellationToken = default(CancellationToken)) + { + await this.DbContext.AddRangeAsync((IEnumerable)entities, cancellationToken); + int num = await this.DbContext.SaveChangesAsync(cancellationToken); + return (IEnumerable)entities; + } + + /// + public virtual TEntity Remove(TEntity entity) + { + this.DbContext.Remove(entity); + this.DbContext.SaveChanges(); + return entity; + } + + /// + public virtual async Task RemoveAsync( + TEntity entity, + CancellationToken cancellationToken = default(CancellationToken)) + { + this.DbContext.Remove(entity); + int num = await this.DbContext.SaveChangesAsync(cancellationToken); + return entity; + } + + /// + public virtual int Delete(Expression> predicate) + { + return DbSet().Where(predicate).ExecuteDelete(); + } + + /// + public virtual IEnumerable RemoveRange(ICollection entities) + { + this.DbSet().RemoveRange((IEnumerable)entities); + this.DbContext.SaveChanges(); + return (IEnumerable)entities; + } + + /// + public virtual async Task> RemoveRangeAsync( + ICollection entities, + CancellationToken cancellationToken = default(CancellationToken)) + { + this.DbSet().RemoveRange((IEnumerable)entities); + int num = await this.DbContext.SaveChangesAsync(cancellationToken); + return (IEnumerable)entities; + } + + /// + public virtual TEntity Update(TEntity entity) + { + this.DbContext.Update(entity); + this.DbContext.SaveChanges(); + return entity; + } + + /// + public virtual async Task UpdateAsync( + TEntity entity, + CancellationToken cancellationToken = default(CancellationToken)) + { + this.DbContext.Update(entity); + int num = await this.DbContext.SaveChangesAsync(cancellationToken); + return entity; + } + } +} diff --git a/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs b/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs index cb2ebabed..46ebea845 100644 --- a/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs +++ b/Dfe.Academies.Application/ApplicationServiceConfigExtensions.cs @@ -1,10 +1,14 @@ 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; @@ -33,12 +37,16 @@ public static IServiceCollection AddApplicationDependencyGroup( 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 => @@ -47,6 +55,9 @@ public static IServiceCollection AddApplicationDependencyGroup( services.AddDbContext(options => options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); + services.AddDbContext(options => + options.UseSqlServer(config.GetConnectionString("DefaultConnection"))); + return services; } } diff --git a/Dfe.Academies.Application/Dfe.Academies.Application.csproj b/Dfe.Academies.Application/Dfe.Academies.Application.csproj index 0b9c9fca4..ba4840010 100644 --- a/Dfe.Academies.Application/Dfe.Academies.Application.csproj +++ b/Dfe.Academies.Application/Dfe.Academies.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs b/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs new file mode 100644 index 000000000..3de5270d6 --- /dev/null +++ b/Dfe.Academies.Application/MappingProfiles/PersonProfile.cs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..60fcb9e6e --- /dev/null +++ b/Dfe.Academies.Application/Models/ConstituencyWithMemberContactDetails.cs @@ -0,0 +1,6 @@ +using Dfe.Academies.Domain.Persons; + +namespace Dfe.Academies.Application.Models +{ + public record ConstituencyWithMemberContactDetails(Constituency Constituency, MemberContactDetails MemberContactDetails); +} diff --git a/Dfe.Academies.Application/Models/Person.cs b/Dfe.Academies.Application/Models/Person.cs new file mode 100644 index 000000000..f08a8390d --- /dev/null +++ b/Dfe.Academies.Application/Models/Person.cs @@ -0,0 +1,14 @@ +namespace Dfe.Academies.Application.Models +{ + public class Person + { + public int Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } + public string DisplayName { get; set; } + public string DisplayNameWithTitle { get; set; } + public string Role { get; set; } + public string ConstituencyName { get; set; } + } +} diff --git a/Dfe.Academies.Application/Persons/IPersonsQueries.cs b/Dfe.Academies.Application/Persons/IPersonsQueries.cs new file mode 100644 index 000000000..2e760b1cf --- /dev/null +++ b/Dfe.Academies.Application/Persons/IPersonsQueries.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..ef3f9b94d --- /dev/null +++ b/Dfe.Academies.Application/Persons/PersonsQueries.cs @@ -0,0 +1,46 @@ +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.Domain/Persons/Constituency.cs b/Dfe.Academies.Domain/Persons/Constituency.cs new file mode 100644 index 000000000..6435b6bb5 --- /dev/null +++ b/Dfe.Academies.Domain/Persons/Constituency.cs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..a60aff7ad --- /dev/null +++ b/Dfe.Academies.Domain/Persons/MemberContactDetails.cs @@ -0,0 +1,15 @@ +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/Repositories/IRepository.cs b/Dfe.Academies.Domain/Repositories/IRepository.cs new file mode 100644 index 000000000..a46d8b6af --- /dev/null +++ b/Dfe.Academies.Domain/Repositories/IRepository.cs @@ -0,0 +1,232 @@ +using System.Linq.Expressions; + +namespace Dfe.Academies.Domain.Repositories +{ + /// Repository + /// + public interface IRepository where TEntity : class, new() + { + /// Returns a queryable (un-resolved!!!!) list of objects. + /// Do not expose IQueryable outside of the domain layer + IQueryable Query(); + + /// + /// Returns an enumerated (resolved!) list of objects based on known query predicate. + /// + /// + /// + /// We know that its the same as doing IQueryable`T.Where(p=> p.value=x).Enumerate but this limits the repo to only ever returning resolved lists. + ICollection Fetch(Expression> predicate); + + /// + /// Asynchronously returns an enumerated (resolved!) list of objects based on known query predicate. + /// + /// + /// + /// + /// We know that its the same as doing IQueryable`T.Where(p=> p.value=x).Enumerate but this limits the repo to only ever returning resolved lists. + Task> FetchAsync( + Expression> predicate, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// 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 + /// without making a request to the store. Otherwise, a request is made to the + /// store for an entity with the given primary key value and this entity, if + /// found, is attached to the context and returned. If no entity is found in + /// the context or the store, then null is returned. + /// + /// The key values. + /// The entity found, or null. + TEntity Find(params object[] keyValues); + + /// + /// 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 + /// without making a request to the store. Otherwise, a request is made to the + /// store for an entity with the given primary key value and this entity, if + /// found, is attached to the context and returned. If no entity is found in + /// the context or the store, then null is returned. + /// + /// The key values. + /// 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. + /// + /// A function to test an entity for a condition + /// + /// The entity found, or null + Task FindAsync( + Expression> predicate, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// 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 + /// without making a request to the store. Otherwise, a request is made to the + /// store for an entity with the given primary key value and this entity, if + /// found, is attached to the context and returned. If no entity is found in + /// the context or the store -or- more than one entity is found, then an + /// InvalidOperationException is thrown. + /// + /// The key values. + /// The entity found + /// 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); + + /// + /// 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 + /// without making a request to the store. Otherwise, a request is made to the + /// store for an entity with the given primary key value and this entity, if + /// found, is attached to the context and returned. If no entity is found in + /// the context or the store -or- more than one entity is found, then an + /// InvalidOperationException is thrown. + /// + /// The key values. + /// The entity found + /// 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. + /// + /// 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. + /// + Task GetAsync(Expression> predicate); + + /// + /// Adds the given entity to the context underlying the set in the Added state + /// such that it will be inserted into the database when SaveChanges is called. + /// + /// The entity to add + /// The entity + /// Note that entities that are already in the context in some other state will + /// have their state set to Added. Add is a no-op if the entity is already in + /// the context in the Added state. + TEntity Add(TEntity entity); + + /// + /// Asynchronously adds the given entity to the context underlying the set in the Added state + /// such that it will be inserted into the database when SaveChanges is called. + /// + /// The entity to add + /// + /// The entity + /// Note that entities that are already in the context in some other state will + /// have their state set to Added. Add is a no-op if the entity is already in + /// the context in the Added state. + Task AddAsync(TEntity entity, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// + /// + /// + /// + IEnumerable AddRange(ICollection entities); + + + /// + /// + /// + /// + /// + /// + Task> AddRangeAsync( + ICollection entities, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Marks the given entity as Deleted such that it will be deleted from the database + /// when SaveChanges is called. Note that the entity must exist in the context + /// in some other state before this method is called. + /// + /// The entity to remove + /// The entity + /// Note that if the entity exists in the context in the Added state, then this + /// method will cause it to be detached from the context. This is because an + /// Added entity is assumed not to exist in the database such that trying to + /// delete it does not make sense. + TEntity Remove(TEntity entity); + + /// + /// Asynchronously marks the given entity as Deleted such that it will be deleted from the database + /// when SaveChanges is called. Note that the entity must exist in the context + /// in some other state before this method is called. + /// + /// The entity to remove + /// + /// The entity + /// Note that if the entity exists in the context in the Added state, then this + /// method will cause it to be detached from the context. This is because an + /// Added entity is assumed not to exist in the database such that trying to + /// delete it does not make sense. + Task RemoveAsync(TEntity entity, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Executes a delete statement filtering the rows to be deleted. + /// + /// The predicate. + /// The number of row deleted. + /// + /// When executing this method, the statement is immediately executed on the + /// database provider and is not part of the change tracking system. Also, changes + /// will not be reflected on any entities that have already been materialized + /// in the current contex + /// + int Delete(Expression> predicate); + + /// + /// Removes the given collection of entities from the DbContext + /// + /// The collection of entities to remove. + IEnumerable RemoveRange(ICollection entities); + + /// + /// Asynchronously removes the given collection of entities from the DbContext + /// + /// The collection of entities to remove. + /// + Task> RemoveRangeAsync( + ICollection entities, + CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Updates the given entity in the DbContext and executes SaveChanges() + /// + /// The entity to update. + TEntity Update(TEntity entity); + + /// + /// Asynchronously updates the given entity in the DbContext and executes SaveChanges() + /// + /// The entity to update. + /// + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Dockerfile.PersonsApi b/Dockerfile.PersonsApi new file mode 100644 index 000000000..21e5e79a1 --- /dev/null +++ b/Dockerfile.PersonsApi @@ -0,0 +1,33 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build +WORKDIR /build + +ENV DEBIAN_FRONTEND=noninteractive + +COPY . . + +RUN --mount=type=secret,id=github_token dotnet nuget add source --username USERNAME --password $(cat /run/secrets/github_token) --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json" +RUN dotnet build -c Release PersonsApi +RUN dotnet publish PersonsApi -c Release -o /app --no-restore + +RUN dotnet new tool-manifest +RUN dotnet tool install dotnet-ef --version 8.0.7 +ENV PATH="$PATH:/root/.dotnet/tools" + +ARG ASPNET_IMAGE_TAG +FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS final + +RUN apt-get update +RUN apt-get install unixodbc curl gnupg -y +RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg +RUN curl https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/msprod.list +RUN apt-get update +RUN ACCEPT_EULA=Y apt-get install msodbcsql18 mssql-tools18 -y + +COPY --from=build /app /app + +WORKDIR /app +COPY ./script/personsapi.docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +ENV ASPNETCORE_HTTP_PORTS 80 +EXPOSE 80/tcp diff --git a/PersonsApi/Controllers/ConstituenciesController.cs b/PersonsApi/Controllers/ConstituenciesController.cs new file mode 100644 index 000000000..d7645e211 --- /dev/null +++ b/PersonsApi/Controllers/ConstituenciesController.cs @@ -0,0 +1,37 @@ +using Dfe.Academies.Application.Models; +using Dfe.Academies.Application.Persons; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace PersonsApi.Controllers +{ + [ApiController] + [ApiVersion("1.0")] + [Route("v{version:apiVersion}/[controller]")] + [SwaggerTag("Persons Endpoints")] + public class ConstituenciesController : ControllerBase + { + private readonly IPersonsQueries _personQueries; + + public ConstituenciesController(IPersonsQueries personQueries) + { + _personQueries = personQueries; + } + + [HttpGet("{constituencyName}/mp")] + [SwaggerOperation(Summary = "Retrieve Member of Parliament by constituency name", Description = "Receives a constituency name and returns a Person object representing the Member of Parliament.")] + [SwaggerResponse(200, "A Person object representing the Member of Parliament.", typeof(Person))] + [SwaggerResponse(404, "Constituency not found")] + public async Task GetAsync([FromRoute] string constituencyName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(constituencyName)) + { + return BadRequest("Constituency cannot be null or empty"); + } + + var result = await _personQueries.GetMemberOfParliamentByConstituencyAsync(constituencyName, cancellationToken); + + return result is null ? NotFound() : Ok(result); + } + } +} diff --git a/PersonsApi/Extensions/PredicateBuilder.cs b/PersonsApi/Extensions/PredicateBuilder.cs new file mode 100644 index 000000000..ce9440643 --- /dev/null +++ b/PersonsApi/Extensions/PredicateBuilder.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace PersonsApi.Extensions +{ + /// + /// http://www.albahari.com/nutshell/predicatebuilder.aspx + /// + public static class PredicateBuilder + { + public static Expression> True() { return f => true; } + public static Expression> False() { return f => false; } + + public static Expression> Or(this Expression> expr1, + Expression> expr2) + { + var invokedExpr = Expression.Invoke(expr2, expr1.Parameters.Cast()); + return Expression.Lambda> + (Expression.OrElse(expr1.Body, invokedExpr), expr1.Parameters); + } + + public static Expression> And(this Expression> expr1, + Expression> expr2) + { + var invokedExpr = Expression.Invoke(expr2, expr1.Parameters.Cast()); + return Expression.Lambda> + (Expression.AndAlso(expr1.Body, invokedExpr), expr1.Parameters); + } + } +} diff --git a/PersonsApi/Extensions/ServiceCollectionExtensions.cs b/PersonsApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..639b9ee47 --- /dev/null +++ b/PersonsApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +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/Extensions/StringExtensions.cs b/PersonsApi/Extensions/StringExtensions.cs new file mode 100644 index 000000000..cf4b73c5d --- /dev/null +++ b/PersonsApi/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace PersonsApi.Extensions +{ + public static class StringExtensions + { + public static int ToInt(this string value) + { + return int.TryParse(value, out var result) ? result : 0; + } + } +} diff --git a/PersonsApi/Middleware/ApiKeyMiddleware.cs b/PersonsApi/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 000000000..2fff692d5 --- /dev/null +++ b/PersonsApi/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,52 @@ +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 new file mode 100644 index 000000000..b6f10cd93 --- /dev/null +++ b/PersonsApi/Middleware/ExceptionHandlerMiddleware.cs @@ -0,0 +1,42 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PersonsApi.ResponseModels; + +public class ExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + public ExceptionHandlerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext httpContext, ILogger logger) + { + try + { + await _next(httpContext); + } + catch (Exception ex) + { + logger.LogError($"Something went wrong: {ex}"); + await HandleExceptionAsync(httpContext, ex, logger); + } + } + private async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger) + { + + logger.LogError(exception.Message); + logger.LogError(exception.StackTrace); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; + await context.Response.WriteAsync(new ErrorResponse() + { + StatusCode = context.Response.StatusCode, + Message = "Internal Server Error: " + exception.Message + }.ToString()); + } +} \ No newline at end of file diff --git a/PersonsApi/Middleware/UrlDecoderMiddleware.cs b/PersonsApi/Middleware/UrlDecoderMiddleware.cs new file mode 100644 index 000000000..387f4f5b7 --- /dev/null +++ b/PersonsApi/Middleware/UrlDecoderMiddleware.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.WebUtilities; + +namespace PersonsApi.Middleware +{ + public class UrlDecoderMiddleware + { + private readonly RequestDelegate _next; + + public UrlDecoderMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var queryString = context.Request.QueryString.ToString(); + var decodedQueryString = HttpUtility.UrlDecode(queryString); + var newQuery = QueryHelpers.ParseQuery(decodedQueryString); + var items = newQuery + .SelectMany(x => x.Value, (col, value) => new KeyValuePair(col.Key, value)).ToList(); + var qb = new QueryBuilder(items); + context.Request.QueryString = qb.ToQueryString(); + + await _next(context); + } + } +} \ No newline at end of file diff --git a/PersonsApi/PersonsApi.csproj b/PersonsApi/PersonsApi.csproj new file mode 100644 index 000000000..d8f9cfc28 --- /dev/null +++ b/PersonsApi/PersonsApi.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/PersonsApi/Program.cs b/PersonsApi/Program.cs new file mode 100644 index 000000000..1a58e1905 --- /dev/null +++ b/PersonsApi/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using PersonsApi; +using PersonsApi.SerilogCustomEnrichers; + +var builder = WebApplication.CreateBuilder(args); + +var startup = new Startup(builder.Configuration); + +startup.ConfigureServices(builder.Services); + +builder.Host.UseSerilog((context, services, loggerConfiguration) => +{ + var enricher = services.GetRequiredService(); + + // TODO EA + //loggerConfiguration + // .ReadFrom.Configuration(context.Configuration) + // .WriteTo.ApplicationInsights(services.GetRequiredService(), TelemetryConverter.Traces) + // .Enrich.FromLogContext() + // .Enrich.With(enricher) + // .WriteTo.Console(); +}); + +builder.Services.AddApplicationDependencyGroup(builder.Configuration); + +var app = builder.Build(); + +var provider = app.Services.GetRequiredService(); + +startup.Configure(app, app.Environment, provider); + +ILogger logger = app.Services.GetRequiredService>(); + +logger.LogInformation("Logger is working..."); + +app.Run(); diff --git a/PersonsApi/Properties/launchSettings.json b/PersonsApi/Properties/launchSettings.json new file mode 100644 index 000000000..fa4b31c2c --- /dev/null +++ b/PersonsApi/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:37794", + "sslPort": 44333 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "PersonsApi": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7089;http://localhost:5277", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PersonsApi/ResponseModels/ApiResponseV2.cs b/PersonsApi/ResponseModels/ApiResponseV2.cs new file mode 100644 index 000000000..bc095b0bf --- /dev/null +++ b/PersonsApi/ResponseModels/ApiResponseV2.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace PersonsApi.ResponseModels +{ + public class ApiResponseV2 where TResponse : class + { + public IEnumerable Data { get; set; } + public PagingResponse Paging { get; set; } + + public ApiResponseV2() => Data = new List(); + + public ApiResponseV2(IEnumerable data, PagingResponse pagingResponse) + { + Data = data; + Paging = pagingResponse; + } + + public ApiResponseV2(TResponse data) => Data = new List{ data }; + + } +} \ No newline at end of file diff --git a/PersonsApi/ResponseModels/ApiSingleResponseV2.cs b/PersonsApi/ResponseModels/ApiSingleResponseV2.cs new file mode 100644 index 000000000..34addada2 --- /dev/null +++ b/PersonsApi/ResponseModels/ApiSingleResponseV2.cs @@ -0,0 +1,10 @@ +namespace PersonsApi.ResponseModels +{ + public class ApiSingleResponseV2 where TResponse : class + { + public TResponse Data { get; set; } + + public ApiSingleResponseV2() => Data = null; + public ApiSingleResponseV2(TResponse data) => Data = data ; + } +} \ No newline at end of file diff --git a/PersonsApi/ResponseModels/ApiUser.cs b/PersonsApi/ResponseModels/ApiUser.cs new file mode 100644 index 000000000..cf01edb0e --- /dev/null +++ b/PersonsApi/ResponseModels/ApiUser.cs @@ -0,0 +1,8 @@ +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/ResponseModels/ErrorResponse.cs b/PersonsApi/ResponseModels/ErrorResponse.cs new file mode 100644 index 000000000..18e66cc7c --- /dev/null +++ b/PersonsApi/ResponseModels/ErrorResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +namespace PersonsApi.ResponseModels +{ + public class ErrorResponse + { + public int StatusCode { get; set; } + public string Message { get; set; } + public override string ToString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/PersonsApi/ResponseModels/PagingResponse.cs b/PersonsApi/ResponseModels/PagingResponse.cs new file mode 100644 index 000000000..8a1d2ab63 --- /dev/null +++ b/PersonsApi/ResponseModels/PagingResponse.cs @@ -0,0 +1,9 @@ +namespace PersonsApi.ResponseModels +{ + public class PagingResponse + { + public int Page { get; set; } + public int RecordCount { get; set; } + public string NextPageUrl { get; set; } + } +} \ No newline at end of file diff --git a/PersonsApi/ResponseModels/PagingResponseFactory.cs b/PersonsApi/ResponseModels/PagingResponseFactory.cs new file mode 100644 index 000000000..b65572bd2 --- /dev/null +++ b/PersonsApi/ResponseModels/PagingResponseFactory.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace PersonsApi.ResponseModels +{ + public static class PagingResponseFactory + { + public static PagingResponse Create(int page, int count, int recordCount, HttpRequest request) + { + var pagingResponse = new PagingResponse + { + RecordCount = recordCount, + Page = page + }; + + if ((count * page) >= recordCount) return pagingResponse; + + var queryAttributes = request.Query + .Where(q => q.Key != nameof(page) && q.Key != nameof(count)) + .Select(q => new KeyValuePair(q.Key, q.Value)); + + var queryBuilder = new QueryBuilder(queryAttributes) + { + {nameof(page), $"{page + 1}"}, + {nameof(count), $"{count}"} + }; + + pagingResponse.NextPageUrl = $"{request.Path}{queryBuilder}"; + + return pagingResponse; + } + + public static Dfe.Academies.Contracts.V4.PagingResponse CreateV4PagingResponse(int page, int count, int recordCount, HttpRequest request) + { + var pagingResponse = new Dfe.Academies.Contracts.V4.PagingResponse + { + RecordCount = recordCount, + Page = page + }; + + if ((count * page) >= recordCount) return pagingResponse; + + var queryAttributes = request.Query + .Where(q => q.Key != nameof(page) && q.Key != nameof(count)) + .Select(q => new KeyValuePair(q.Key, q.Value)); + + var queryBuilder = new QueryBuilder(queryAttributes) + { + {nameof(page), $"{page + 1}"}, + {nameof(count), $"{count}"} + }; + + pagingResponse.NextPageUrl = $"{request.Path}{queryBuilder}"; + + return pagingResponse; + } + } +} \ No newline at end of file diff --git a/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs b/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs new file mode 100644 index 000000000..af56d6cf6 --- /dev/null +++ b/PersonsApi/SerilogCustomEnrichers/ApiUserEnricher.cs @@ -0,0 +1,52 @@ +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/Services/ApiClient.cs b/PersonsApi/Services/ApiClient.cs new file mode 100644 index 000000000..acdf791a6 --- /dev/null +++ b/PersonsApi/Services/ApiClient.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace PersonsApi.Services +{ + public abstract class ApiClient + { + private readonly IHttpClientFactory _clientFactory; + private readonly ILogger _logger; + private string _httpClientName; + + protected ApiClient(IHttpClientFactory clientFactory, ILogger logger, string httpClientName) + { + _clientFactory = clientFactory; + _logger = logger; + _httpClientName = httpClientName; + } + + public async Task Get(string endpoint) where T : class + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + + var client = CreateHttpClient(); + + var response = await client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + var result = JsonConvert.DeserializeObject(content); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + throw; + } + } + + private HttpClient CreateHttpClient() + { + var client = _clientFactory.CreateClient(_httpClientName); + + return client; + } + } +} diff --git a/PersonsApi/Services/DateTimeService.cs b/PersonsApi/Services/DateTimeService.cs new file mode 100644 index 000000000..8854c4fed --- /dev/null +++ b/PersonsApi/Services/DateTimeService.cs @@ -0,0 +1,27 @@ +using System; + +namespace PersonsApi.Services +{ + /// + /// Mechanism for retrieving DateTime data that provides a way to intercept and set the returned values to facilitate testing + /// + /// + /// + /// DateTime expected = DateTime.UtcNow; + /// DateTimeSource.UtcNow = () => expected; + /// + /// + public static class DateTimeSource + { + /// + /// Returns the value of unless overridden for testing. + /// + public static Func UtcNow { get; set; } = () => DateTime.UtcNow; + + /// + /// Returns the value of unless overridden for testing. + /// + public static Func UkTime { get; set; } = () => + TimeZoneInfo.ConvertTimeBySystemTimeZoneId(DateTime.Now, "GMT Standard Time"); + } +} \ No newline at end of file diff --git a/PersonsApi/Startup.cs b/PersonsApi/Startup.cs new file mode 100644 index 000000000..44f4a84b7 --- /dev/null +++ b/PersonsApi/Startup.cs @@ -0,0 +1,202 @@ +using System.Text.Json.Serialization; +using Dfe.Academisation.CorrelationIdMiddleware; +using Microsoft.AspNetCore.HttpOverrides; + +namespace PersonsApi +{ + //using DatabaseModels; + //using Gateways; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Mvc.ApiExplorer; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Middleware; + using Swashbuckle.AspNetCore.SwaggerUI; + using System; + using System.IO; + using System.Reflection; + //using PersonsApi.Configuration; + using PersonsApi.ResponseModels; + using PersonsApi.SerilogCustomEnrichers; + using UseCases; + using Microsoft.FeatureManagement; + using PersonsApi.Services; + using Microsoft.AspNetCore.Http; + using System.Text; + using NetEscapades.AspNetCore.SecurityHeaders; + using PersonsApi.Swagger; + using Dfe.Academies.Application.MappingProfiles; + + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers().AddJsonOptions(c => {c.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());}); + services.AddApiVersioning(); + services.AddFeatureManagement(); + + services.AddScoped(); + + services.AddApiVersioning(config => + { + config.DefaultApiVersion = new Microsoft.AspNetCore.Mvc.ApiVersion(1, 0); + config.AssumeDefaultVersionWhenUnspecified = true; + config.ReportApiVersions = true; + }); + services.AddVersionedApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); + services.AddSwaggerGen(c => + { + string descriptions = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + string descriptionsPath = Path.Combine(AppContext.BaseDirectory, descriptions); + if (File.Exists(descriptionsPath)) c.IncludeXmlComments(descriptionsPath); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + //c.IncludeXmlComments(xmlPath); + c.EnableAnnotations(); + }); + services.ConfigureOptions(); + services.AddHttpContextAccessor(); + + services + .AddOptions() + .Configure((swaggerUiOptions, httpContextAccessor) => + { + // 2. Take a reference of the original Stream factory which reads from Swashbuckle's embedded resources + var originalIndexStreamFactory = swaggerUiOptions.IndexStream; + // 3. Override the Stream factory + swaggerUiOptions.IndexStream = () => + { + // 4. Read the original index.html file + using var originalStream = originalIndexStreamFactory(); + using var originalStreamReader = new StreamReader(originalStream); + var originalIndexHtmlContents = originalStreamReader.ReadToEnd(); + // 5. Get the request-specific nonce generated by NetEscapades.AspNetCore.SecurityHeaders + var requestSpecificNonce = httpContextAccessor.HttpContext.GetNonce(); + // 6. Replace inline `