Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DRAFT refactor: Improve page + authentication querying #468

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Dfe.PlanTech.Application.Persistence.Interfaces;
using Microsoft.Extensions.Logging;

namespace Dfe.PlanTech.Application.Content.Queries;

public class GetPageAuthenticationQuery
{
private readonly ICmsDbContext _db;
private readonly GetPageFromContentfulQuery _getPageFromContentfulQuery;
private readonly ILogger<GetPageAuthenticationQuery> _logger;

public GetPageAuthenticationQuery(ICmsDbContext db, GetPageFromContentfulQuery getPageFromContentfulQuery, ILogger<GetPageAuthenticationQuery> logger)
{
_db = db;
_getPageFromContentfulQuery = getPageFromContentfulQuery;
_logger = logger;
}

public async Task<bool> PageRequiresAuthentication(string slug, CancellationToken cancellationToken)
{
var result = await CheckPageAuthenticationFromDb(slug, cancellationToken) ??
await CheckPageAuthenticationFromContentful(slug, cancellationToken);

return result ?? false;
}

private async Task<bool?> CheckPageAuthenticationFromDb(string slug, CancellationToken cancellationToken)
{
try
{
var page = await _db.FirstOrDefaultAsync(_db.Pages.Where(page => page.Slug == slug).Select(page => new
{
page.Id,
page.RequiresAuthorisation
}), cancellationToken);

return page?.RequiresAuthorisation;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking page authentication in DB for {slug}", slug);
return null;
}
}

private async Task<bool?> CheckPageAuthenticationFromContentful(string slug, CancellationToken cancellationToken)
{
try
{
var result = await _getPageFromContentfulQuery.GetPageBySlug(slug, new[] { "fields.requiresAuthorisation" }, cancellationToken);

return result?.RequiresAuthorisation;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking page authentication in Contentful for {slug}", slug);
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Dfe.PlanTech.Application.Exceptions;
using Dfe.PlanTech.Application.Persistence.Interfaces;
using Dfe.PlanTech.Application.Persistence.Models;
using Dfe.PlanTech.Domain.Content.Models;
using Dfe.PlanTech.Domain.Content.Models.Options;
using Dfe.PlanTech.Domain.Content.Queries;
using Dfe.PlanTech.Infrastructure.Application.Models;
using Microsoft.Extensions.Logging;

namespace Dfe.PlanTech.Application.Content.Queries;

public class GetPageFromContentfulQuery : IGetPageQuery
{
private readonly IContentRepository _repository;
private readonly ILogger<GetPageFromContentfulQuery> _logger;
private readonly GetPageFromContentfulOptions _options;

public GetPageFromContentfulQuery(IContentRepository repository, ILogger<GetPageFromContentfulQuery> logger, GetPageFromContentfulOptions options)
{
_repository = repository;
_logger = logger;
_options = options;
}

/// <summary>
/// Retrieves the page for the given slug from Contentful
/// </summary>
/// <param name="slug"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="KeyNotFoundException"></exception>
/// <exception cref="ContentfulDataUnavailableException"></exception>
public async Task<Page?> GetPageBySlug(string slug, CancellationToken cancellationToken = default)
{
var options = CreateGetEntityOptions(slug);

return await FetchFromContentful(slug, options, cancellationToken);
}

/// <summary>
/// Get page by slug + only return specific fields
/// </summary>
/// <param name="slug"></param>
/// <param name="fieldsToReturn"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="ContentfulDataUnavailableException"></exception>

public async Task<Page?> GetPageBySlug(string slug, IEnumerable<string> fieldsToReturn, CancellationToken cancellationToken = default)
{
var options = CreateGetEntityOptions(slug);
options.Select = fieldsToReturn;

return await FetchFromContentful(slug, options, cancellationToken);
}

/// <exception cref="ContentfulDataUnavailableException"></exception>
private async Task<Page?> FetchFromContentful(string slug, GetEntitiesOptions options, CancellationToken cancellationToken)
{
try
{
var pages = await _repository.GetEntities<Page>(options, cancellationToken);

var page = pages.FirstOrDefault() ?? throw new KeyNotFoundException($"Could not find page with slug {slug}");

return page;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching page {slug} from Contentful", slug);
throw new ContentfulDataUnavailableException($"Could not retrieve page with slug {slug}", ex);
}
}

private GetEntitiesOptions CreateGetEntityOptions(string slug) =>
new(_options.Include, new[] { new ContentQueryEquals() { Field = "fields.slug", Value = slug } });
}
65 changes: 5 additions & 60 deletions src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
using AutoMapper;
using Dfe.PlanTech.Application.Caching.Interfaces;
using Dfe.PlanTech.Application.Core;
using Dfe.PlanTech.Application.Exceptions;
using Dfe.PlanTech.Application.Persistence.Interfaces;
using Dfe.PlanTech.Application.Persistence.Models;
using Dfe.PlanTech.Domain.Content.Models;
using Dfe.PlanTech.Domain.Content.Queries;
using Dfe.PlanTech.Infrastructure.Application.Models;
using Microsoft.Extensions.Logging;

namespace Dfe.PlanTech.Application.Content.Queries;

public class GetPageQuery : ContentRetriever, IGetPageQuery
public class GetPageQuery : IGetPageQuery
{
private readonly ILogger<GetPageQuery> _logger;
private readonly IQuestionnaireCacher _cacher;
private readonly IGetPageQuery _getPageFromDbQuery;
readonly string _getEntityEnvVariable = Environment.GetEnvironmentVariable("CONTENTFUL_GET_ENTITY_INT") ?? "4";
private readonly IGetPageQuery _getPageFromContentfulQuery;

public GetPageQuery(GetPageFromDbQuery getPageFromDbQuery, ILogger<GetPageQuery> logger, IQuestionnaireCacher cacher, IContentRepository repository) : base(repository)
public GetPageQuery(GetPageFromContentfulQuery getPageFromContentfulQuery, GetPageFromDbQuery getPageFromDbQuery)
{
_cacher = cacher;
_logger = logger;
_getPageFromDbQuery = getPageFromDbQuery;
_getPageFromContentfulQuery = getPageFromContentfulQuery;
}

/// <summary>
Expand All @@ -32,52 +21,8 @@ public GetPageQuery(GetPageFromDbQuery getPageFromDbQuery, ILogger<GetPageQuery>
/// <returns>Page matching slug</returns>
public async Task<Page?> GetPageBySlug(string slug, CancellationToken cancellationToken = default)
{
var page = await _getPageFromDbQuery.GetPageBySlug(slug, cancellationToken) ?? await GetPageFromContentful(slug, cancellationToken);

UpdateSectionTitle(page);
var page = await _getPageFromDbQuery.GetPageBySlug(slug, cancellationToken) ?? await _getPageFromContentfulQuery.GetPageBySlug(slug, cancellationToken);

return page;
}

/// <summary>
/// Retrieves the page for the given slug from Contentful
/// </summary>
/// <param name="slug"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="FormatException"></exception>
/// <exception cref="KeyNotFoundException"></exception>
/// <exception cref="ContentfulDataUnavailableException"></exception>
private async Task<Page> GetPageFromContentful(string slug, CancellationToken cancellationToken)
{
if (!int.TryParse(_getEntityEnvVariable, out int getEntityValue))
{
throw new FormatException($"Could not parse CONTENTFUL_GET_ENTITY_INT environment variable to int. Value: {_getEntityEnvVariable}");
}

try
{
var options = new GetEntitiesOptions(getEntityValue,
new[] { new ContentQueryEquals() { Field = "fields.slug", Value = slug } });
var pages = await repository.GetEntities<Page>(options, cancellationToken);

var page = pages.FirstOrDefault() ?? throw new KeyNotFoundException($"Could not find page with slug {slug}");

return page;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching page {slug} from Contentful", slug);
throw new ContentfulDataUnavailableException($"Could not retrieve page with slug {slug}", ex);
}
}

private void UpdateSectionTitle(Page page)
{
if (page.DisplayTopicTitle)
{
var cached = _cacher.Cached!;
page.SectionTitle = cached.CurrentSectionTitle;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ public interface ICmsDbContext
public Task<PageDbEntity?> GetPageBySlug(string slug, CancellationToken cancellationToken = default);

public IQueryable<RichTextContentDbEntity> RichTextContentsByPageSlug(string pageSlug);

public Task<T?> FirstOrDefaultAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ namespace Dfe.PlanTech.Application.Persistence.Interfaces;

public interface IGetEntitiesOptions
{
IEnumerable<IContentQuery>? Queries { get; init; }
int Include { get; init; }
/// <summary>
/// Filter queries (e.g. where field equals value)
/// </summary>
public IEnumerable<IContentQuery>? Queries { get; init; }

/// <summary>
/// Depth of references to include. 1 = parent only, 2 = parent and child, etc.
/// </summary>
public int Include { get; init; }

/// <summary>
/// What fields to return from Contentful
/// </summary>
/// <remarks>
/// If null, return all
/// </remarks>
public IEnumerable<string>? Select { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public GetEntitiesOptions()
{
}

public IEnumerable<string>? Select { get; set; }

public IEnumerable<IContentQuery>? Queries { get; init; }

public int Include { get; init; } = 2;
Expand Down
2 changes: 1 addition & 1 deletion src/Dfe.PlanTech.AzureFunctions/Functions/QueueReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private async Task ProcessMessage(ServiceBusReceivedMessage message, ServiceBusM

private async Task<ContentComponentDbEntity?> GetExistingDbEntity(ContentComponentDbEntity entity, CancellationToken cancellationToken)
{
var model = _db.Model.FindEntityType(entity.GetType()) ?? throw new Exception($"Could not find model in database for {entity.GetType()}");
var model = _db.Model.FindEntityType(entity.GetType()) ?? throw new KeyNotFoundException($"Could not find model in database for {entity.GetType()}");

var dbSet = GetIQueryableForEntity(model);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Dfe.PlanTech.Domain.Content.Models.Options
{
public class GetPageFromContentfulOptions
{
/// <summary>
/// How many reference levels to include in the query
/// </summary>
/// <remarks>
/// E.g. 1 == just the parent, 2 == parent and child, etc.
/// </remarks>
public int Include { get; init; } = 4;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Dfe.PlanTech.Domain.Users.Exceptions;

public class PageNotFoundException : Exception
{
public PageNotFoundException(string message)
: base(message)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Linq.Expressions;
using System.Reflection;
using Contentful.Core.Search;
using Dfe.PlanTech.Application.Persistence.Interfaces;
using Dfe.PlanTech.Infrastructure.Application.Models;
Expand Down Expand Up @@ -58,6 +60,7 @@
if (options != null)
{
queryBuilder = queryBuilder.WithOptions(options);
queryBuilder = queryBuilder.WithSelect(options);
}

return queryBuilder;
Expand All @@ -76,4 +79,33 @@
queryBuilder.Include(options.Include);
return queryBuilder;
}

public static QueryBuilder<T> WithSelect<T>(this QueryBuilder<T> queryBuilder, IGetEntitiesOptions options)
{
if (options.Select == null) return queryBuilder;

var queryStringValues = queryBuilder.GetQueryStringValues();

foreach (var propertyName in options.Select)
{
queryStringValues.Add(new KeyValuePair<string, string>("select", propertyName));
}

return queryBuilder;
}

private static List<KeyValuePair<string, string>> GetQueryStringValues<T>(this QueryBuilder<T> queryBuilder)
{
var fieldInfo = queryBuilder.GetType().GetField("_querystringValues", BindingFlags.NonPublic | BindingFlags.Instance);

Check warning on line 99 in src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/QueryBuilders.cs

View workflow job for this annotation

GitHub Actions / Build and run unit tests

Make sure that this accessibility bypass is safe here. (https://rules.sonarsource.com/csharp/RSPEC-3011)

if (fieldInfo == null) throw new Exception("Couldn't find property for some reason");

Check warning on line 101 in src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/QueryBuilders.cs

View workflow job for this annotation

GitHub Actions / Build and run unit tests

'System.Exception' should not be thrown by user code. (https://rules.sonarsource.com/csharp/RSPEC-112)

var value = fieldInfo.GetValue(queryBuilder);

if (value is List<KeyValuePair<string, string>> list)
return list;

throw new Exception("Err");

Check warning on line 108 in src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/QueryBuilders.cs

View workflow job for this annotation

GitHub Actions / Build and run unit tests

'System.Exception' should not be thrown by user code. (https://rules.sonarsource.com/csharp/RSPEC-112)

}
}
9 changes: 6 additions & 3 deletions src/Dfe.PlanTech.Infrastructure.Data/CmsDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)

}

public Task<List<T>> ToListAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
=> queryable.ToListAsync(cancellationToken: cancellationToken);

public Task<PageDbEntity?> GetPageBySlug(string slug, CancellationToken cancellationToken = default)
=> Pages.Include(page => page.BeforeTitleContent)
.Include(page => page.Content)
Expand All @@ -208,4 +205,10 @@ public Task<List<T>> ToListAsync<T>(IQueryable<T> queryable, CancellationToken c

public IQueryable<RichTextContentDbEntity> RichTextContentsByPageSlug(string pageSlug)
=> RichTextContents.FromSql($"SELECT * FROM [Contentful].[SelectAllRichTextContentForPageSlug]({pageSlug})");

public Task<T?> FirstOrDefaultAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
=> queryable.FirstOrDefaultAsync(cancellationToken);

public Task<List<T>> ToListAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
=> queryable.ToListAsync(cancellationToken: cancellationToken);
}
Loading
Loading