Skip to content

v0.23.0

Compare
Choose a tag to compare
@pdevito3 pdevito3 released this 23 Sep 01:39

Additions and Updates

  • New GetAll and Job features
  • Hangfire integration (details on adding to existing projects below)
  • Moq -> NSubsititute
  • Bump base page size limit to 500
  • Added global usings to the test projects
  • Entity variables in tests don't start with fake anymore
  • Moved BasePaginationParameters and Exceptions and ValueObject to api project (#124)
  • Package bumps and cleanup with the exception of pulumi
  • Remove Command prop from feature scaffolding
  • Better property names for controllers and requests

Fixed

  • Can handle no global git config (#122, #72)
  • Can use . in project name (#111)

Adding Hangfire To an Existing Project

Install

    <PackageReference Include="Hangfire" Version="1.8.5" />
    <PackageReference Include="Hangfire.MemoryStorage" Version="1.8.0" />

Add this to your Infra Registration

services.SetupHangfire(env);

// ---

public static class HangfireConfig
{
    public static void SetupHangfire(this IServiceCollection services, IWebHostEnvironment env)
    {
        services.AddScoped<IJobContextAccessor, JobContextAccessor>();
        services.AddScoped<IJobWithUserContext, JobWithUserContext>();
        // if you want tags with sql server
        // var tagOptions = new TagsOptions() { TagsListStyle = TagsListStyle.Dropdown };
        
        // var hangfireConfig = new MemoryStorageOptions() { };
        services.AddHangfire(config =>
        {
            config
                .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
                .UseMemoryStorage()
                .UseColouredConsoleLogProvider()
                .UseSimpleAssemblyNameTypeSerializer()
                .UseRecommendedSerializerSettings()
                // if you want tags with sql server
                // .UseTagsWithSql(tagOptions, hangfireConfig)
                .UseActivator(new JobWithUserContextActivator(services.BuildServiceProvider()
                    .GetRequiredService<IServiceScopeFactory>()));
        });
        services.AddHangfireServer(options =>
        {
            options.WorkerCount = 10;
            options.ServerName = $"PeakLims-{env.EnvironmentName}";

            if (Consts.HangfireQueues.List().Length > 0)
            {
                options.Queues = Consts.HangfireQueues.List();
            }
        });

    }
}

Update Program.cs

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    AsyncAuthorization = new[] { new HangfireAuthorizationFilter(scope.ServiceProvider) },
    IgnoreAntiforgeryToken = true
});

Add queues to your consts

    public static class HangfireQueues
    {
        // public const string MyFirstQueue = "my-first-queue";
        
        public static string[] List()
        {
            return typeof(HangfireQueues)
                .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
                .Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string))
                .Select(x => (string)x.GetRawConstantValue())
                .ToArray();
        }
    }

Create the following files

namespace PeakLims.Resources.HangfireUtilities;

using Hangfire.Client;
using Hangfire.Common;

public class CurrentUserFilterAttribute : JobFilterAttribute, IClientFilter
{
    public void OnCreating(CreatingContext context)
    {
        var argue = context.Job.Args.FirstOrDefault(x => x is IJobWithUserContext);
        if (argue == null)
            throw new Exception($"This job does not implement the {nameof(IJobWithUserContext)} interface");

        var jobParameters = argue as IJobWithUserContext;
        var user = jobParameters?.User;

        if(user == null)
            throw new Exception($"A User could not be established");

        context.SetJobParameter("User", user);
    }

    public void OnCreated(CreatedContext context)
    {
    }
}
@@ -0,0 +1,23 @@
namespace PeakLims.Resources.HangfireUtilities;

using Hangfire.Dashboard;

public class HangfireAuthorizationFilter : IDashboardAsyncAuthorizationFilter
{
    private readonly IServiceProvider _serviceProvider;
    
    public HangfireAuthorizationFilter(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public Task<bool> AuthorizeAsync(DashboardContext context)
    {
        // TODO alt -- add login handling with cookie handling
        // var heimGuard = _serviceProvider.GetService<IHeimGuardClient>();
        // return await heimGuard.HasPermissionAsync(Permissions.HangfireAccess);

        var env = _serviceProvider.GetService<IWebHostEnvironment>();
        return Task.FromResult(env.IsDevelopment());
    }
}
namespace PeakLims.Resources.HangfireUtilities;

using System.Security.Claims;
using Hangfire;
using Hangfire.Annotations;
using Hangfire.AspNetCore;
using Hangfire.Client;
using Hangfire.Common;
using Services;

public interface IJobWithUserContext
{
    public string? User { get; set; }
}
public class JobWithUserContext : IJobWithUserContext
{
    public string? User { get; set; }
}
public interface IJobContextAccessor
{
    JobWithUserContext? UserContext { get; set; }
}
public class JobContextAccessor : IJobContextAccessor
{
    public JobWithUserContext? UserContext { get; set; }
}

public class JobWithUserContextActivator : AspNetCoreJobActivator
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public JobWithUserContextActivator([NotNull] IServiceScopeFactory serviceScopeFactory) : base(serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
    }

    public override JobActivatorScope BeginScope(JobActivatorContext context)
    {
        var user = context.GetJobParameter<string>("User");

        if (user == null)
        {
            return base.BeginScope(context);
        }

        var serviceScope = _serviceScopeFactory.CreateScope();

        var userContextForJob = serviceScope.ServiceProvider.GetRequiredService<IJobContextAccessor>();
        userContextForJob.UserContext = new JobWithUserContext {User = user};

        return new ServiceJobActivatorScope(serviceScope);
    }
}
namespace PeakLims.Resources.HangfireUtilities;

using Hangfire;
using Hangfire.Annotations;

public class ServiceJobActivatorScope : JobActivatorScope
{
    private readonly IServiceScope _serviceScope;

    public ServiceJobActivatorScope([NotNull] IServiceScope serviceScope)
    {
        _serviceScope = serviceScope ?? throw new ArgumentNullException(nameof(serviceScope));
    }

    public override object Resolve(Type type)
    {
        return ActivatorUtilities.GetServiceOrCreateInstance(_serviceScope.ServiceProvider, type);
    }

    public override void DisposeScope()
    {
        _serviceScope.Dispose();
    }
}

Add a permission to Permissions

    public const string HangfireAccess = nameof(HangfireAccess);

Update your CurrentUserService

public interface ICurrentUserService : IPeakLimsScopedService
{
@@ -17,26 +18,43 @@ public interface ICurrentUserService : IPeakLimsScopedService
public sealed class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IJobContextAccessor _jobContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor, IJobContextAccessor jobContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
        _jobContextAccessor = jobContextAccessor;
    }

    public ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User ?? CreatePrincipalFromJobContextUserId();
    public string? UserId => User?.FindFirstValue(ClaimTypes.NameIdentifier);
    public string? Email => User?.FindFirstValue(ClaimTypes.Email);
    public string? FirstName => User?.FindFirstValue(ClaimTypes.GivenName);
    public string? LastName => User?.FindFirstValue(ClaimTypes.Surname);
    public string? Username => User

        ?.Claims
        ?.FirstOrDefault(x => x.Type is "preferred_username" or "username")
        ?.Value;
    public string? ClientId => User

        ?.Claims
        ?.FirstOrDefault(x => x.Type is "client_id" or "clientId")
        ?.Value;
    public bool IsMachine => ClientId != null;
    
    private ClaimsPrincipal? CreatePrincipalFromJobContextUserId()
    {
        var userId = _jobContextAccessor?.UserContext?.User;
        if (string.IsNullOrEmpty(userId))
        {
            return null;
        }

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId)
        };

        var identity = new ClaimsIdentity(claims, $"hangfirejob-{userId}");
        return new ClaimsPrincipal(identity);
    }
}

Add this to your test fixture

        services.ReplaceServiceWithSingletonMock<IBackgroundJobClient>();

Add this unit test to CurrentUserServiceTests

    [Fact]
    public void can_fallback_to_user_in_job_context()
    {
        // Arrange
        var name = new Faker().Person.UserName;

        var httpContextAccessor = Substitute.For<IHttpContextAccessor>();
        httpContextAccessor.HttpContext.Returns((HttpContext)null);

        var jobContextAccessor = new JobContextAccessor();
        jobContextAccessor.UserContext = new JobWithUserContext()
        {
            User = name
        };

        var currentUserService = new CurrentUserService(httpContextAccessor, jobContextAccessor);

        // Act & Assert
        currentUserService.UserId.Should().Be(name);
    }