Skip to content

Commit

Permalink
Merge pull request #50 from dlcs/feature/humanReadableId
Browse files Browse the repository at this point in the history
Change id's to be generated by sqids and an injected id generator
  • Loading branch information
JackLewis-digirati authored Sep 30, 2024
2 parents 856425e + ade999b commit 966675e
Show file tree
Hide file tree
Showing 10 changed files with 185 additions and 15 deletions.
1 change: 1 addition & 0 deletions src/IIIFPresentation/API.Tests/API.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FakeItEasy" Version="8.3.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using API.Infrastructure.IdGenerator;
using FluentAssertions;
using Sqids;

namespace API.Tests.Infrastructure;

public class SqidsGeneratorTests
{
private readonly SqidsGenerator sqidsGenerator;
private readonly SqidsEncoder<long> sqidsEncoder;

public SqidsGeneratorTests()
{
sqidsEncoder = new SqidsEncoder<long>();
sqidsGenerator = new SqidsGenerator(sqidsEncoder);
}

[Fact]
public void Generate_GeneratesId_WithSeed()
{
// Arrange
var seed = new List<long>()
{
1,
2,
3,
4
};

// Act
var id = sqidsGenerator.Generate(seed);
var decoded = sqidsEncoder.Decode(id);

// Assert
id.Should().NotBeNullOrEmpty();
decoded.Should().Equal(seed);
}

[Fact]
public void Generate_GeneratesId_WithoutSeed()
{
// Act
var id = sqidsGenerator.Generate();
var decoded = sqidsEncoder.Decode(id);

// Assert
id.Should().NotBeNullOrEmpty();
decoded.Count.Should().Be(5);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using System.Net;
using System.Data;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using API.Helpers;
using API.Infrastructure.IdGenerator;
using API.Tests.Integration.Infrastructure;
using Core.Response;
using FakeItEasy;
using FluentAssertions;
using IIIF.Presentation.V3.Strings;
using Microsoft.AspNetCore.Mvc.Testing;
Expand Down Expand Up @@ -72,6 +76,7 @@ public async Task CreateCollection_CreatesCollection_WhenAllValuesProvided()

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
fromDatabase.Id.Length.Should().BeGreaterThan(6);
fromDatabase.Parent.Should().Be(parent);
fromDatabase.Label!.Values.First()[0].Should().Be("test collection");
fromDatabase.Slug.Should().Be("programmatic-child");
Expand Down Expand Up @@ -218,6 +223,35 @@ public async Task CreateCollection_FailsToCreateCollection_WhenCalledWithoutShow
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task GenerateUniqueIdAsync_CreatesNewId()
{
// Arrange
var sqidsGenerator = A.Fake<IIdGenerator>();
A.CallTo(() => sqidsGenerator.Generate(A<List<long>>.Ignored)).Returns("generated");


// Act
var id = await dbContext.Collections.GenerateUniqueIdAsync(1, sqidsGenerator);

// Assert
id.Should().Be("generated");
}

[Fact]
public async Task GenerateUniqueIdAsync_ThrowsException_WhenGeneratingExistingId()
{
// Arrange
var sqidsGenerator = A.Fake<IIdGenerator>();
A.CallTo(() => sqidsGenerator.Generate(A<List<long>>.Ignored)).Returns("root");

// Act
Func<Task> action = () => dbContext.Collections.GenerateUniqueIdAsync(1, sqidsGenerator);

// Assert
await action.Should().ThrowAsync<ConstraintException>();
}

[Fact]
public async Task UpdateCollection_UpdatesCollection_WhenAllValuesProvided()
{
Expand Down
1 change: 1 addition & 0 deletions src/IIIFPresentation/API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Sqids" Version="3.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using API.Auth;
using System.Data;
using API.Auth;
using API.Converters;
using API.Features.Storage.Helpers;
using API.Helpers;
Expand All @@ -13,6 +14,7 @@
using Models.Database.Collections;
using Repository;
using Repository.Helpers;
using IIdGenerator = API.Infrastructure.IdGenerator.IIdGenerator;

namespace API.Features.Storage.Requests;

Expand All @@ -29,6 +31,7 @@ public class CreateCollection(int customerId, UpsertFlatCollection collection, U
public class CreateCollectionHandler(
PresentationContext dbContext,
ILogger<CreateCollection> logger,
IIdGenerator idGenerator,
IOptions<ApiSettings> options)
: IRequestHandler<CreateCollection, ModifyEntityResult<PresentationCollection>>
{
Expand All @@ -47,11 +50,24 @@ public async Task<ModifyEntityResult<PresentationCollection>> Handle(CreateColle
return ModifyEntityResult<PresentationCollection>.Failure(
$"The parent collection could not be found", WriteResult.Conflict);
}

string id;

try
{
id = await dbContext.Collections.GenerateUniqueIdAsync(request.CustomerId, idGenerator, cancellationToken);
}
catch (ConstraintException ex)
{
logger.LogError(ex, "An exception occured while generating a unique id");
return ModifyEntityResult<PresentationCollection>.Failure(
"Could not generate a unique identifier. Please try again", WriteResult.Error);
}

var dateCreated = DateTime.UtcNow;
var collection = new Collection()
var collection = new Collection
{
Id = Guid.NewGuid().ToString(),
Id = id,
Created = dateCreated,
Modified = dateCreated,
CreatedBy = Authorizer.GetUser(),
Expand Down
39 changes: 36 additions & 3 deletions src/IIIFPresentation/API/Helpers/CollectionHelperX.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using API.Converters;
using API.Features.Storage.Helpers;
using Core.Helpers;
using System.Data;
using API.Converters;
using API.Infrastructure.IdGenerator;
using Microsoft.EntityFrameworkCore;
using Models.Database.Collections;

namespace API.Helpers;
Expand All @@ -10,6 +11,8 @@ namespace API.Helpers;
/// </summary>
public static class CollectionHelperX
{
private const int MaxAttempts = 3;

public static string GenerateHierarchicalCollectionId(this Collection collection, UrlRoots urlRoots) =>
$"{urlRoots.BaseUrl}/{collection.CustomerId}{(string.IsNullOrEmpty(collection.FullPath) ? string.Empty : $"/{collection.FullPath}")}";

Expand Down Expand Up @@ -45,4 +48,34 @@ public static Uri GenerateFlatCollectionViewLast(this Collection collection, Url

public static string GenerateFullPath(this Collection collection, string itemSlug) =>
$"{(collection.Parent != null ? $"{collection.Slug}/" : string.Empty)}{itemSlug}";

public static async Task<string> GenerateUniqueIdAsync(this DbSet<Collection> collections,
int customerId, IIdGenerator idGenerator, CancellationToken cancellationToken = default)
{
var isUnique = false;
var id = string.Empty;
var currentAttempt = 0;
var random = new Random();
var maxRandomValue = 25000;

while (!isUnique)
{
if (currentAttempt > MaxAttempts)
{
throw new ConstraintException("Max attempts to generate an identifier exceeded");
}

id = idGenerator.Generate([
customerId,
DateTime.UtcNow.Ticks,
random.Next(0, maxRandomValue)
]);

isUnique = !await collections.AnyAsync(c => c.Id == id, cancellationToken);

currentAttempt++;
}

return id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace API.Infrastructure.IdGenerator;

public interface IIdGenerator
{
string Generate(List<long>? seed = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Sqids;

namespace API.Infrastructure.IdGenerator;

public class SqidsGenerator(SqidsEncoder<long> sqids) : IIdGenerator
{
private const int ListLength = 5;

public string Generate(List<long>? seed = null)
{
if (seed == null)
{
Random rand = new Random();
seed = Enumerable.Range(0, ListLength)
.Select(i => new Tuple<int, long>(rand.Next(int.MaxValue), i))
.OrderBy(i => i.Item1)
.Select(i => i.Item2).ToList();
}

return sqids.Encode(seed);
}
}
16 changes: 14 additions & 2 deletions src/IIIFPresentation/API/Infrastructure/ServiceCollectionX.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
using System.Reflection;
using API.Infrastructure.IdGenerator;
using API.Infrastructure.Mediatr.Behaviours;
using API.Infrastructure.Requests.Pipelines;
using API.Settings;
using MediatR;
using Repository;
using Sqids;

namespace API.Infrastructure;

public static class ServiceCollectionX
{
/// <summary>
/// Add all dataaccess dependencies, including repositories and presentation context
/// Add all dataaccess dependencies, including repositories and presentation context
/// </summary>
public static IServiceCollection AddDataAccess(this IServiceCollection services, IConfiguration configuration)
{
Expand All @@ -30,7 +32,7 @@ public static IServiceCollection AddCaching(this IServiceCollection services, Ca
.AddLazyCache();

/// <summary>
/// Add MediatR services and pipeline behaviours to service collection.
/// Add MediatR services and pipeline behaviours to service collection.
/// </summary>
public static IServiceCollection ConfigureMediatR(this IServiceCollection services)
{
Expand All @@ -39,4 +41,14 @@ public static IServiceCollection ConfigureMediatR(this IServiceCollection servic
.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>))
.AddScoped(typeof(IPipelineBehavior<,>), typeof(CacheInvalidationBehaviour<,>));
}

public static IServiceCollection ConfigureIdGenerator(this IServiceCollection services)
{
return services.AddSingleton(new SqidsEncoder<long>(new()
{
Alphabet = "abcdefghijklmnopqrstuvwxyz0123456789",
MinLength = 6,
}))
.AddSingleton<IIdGenerator, SqidsGenerator>();
}
}
7 changes: 1 addition & 6 deletions src/IIIFPresentation/API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using API.Features.Storage.Validators;
using API.Infrastructure;
using API.Infrastructure.Helpers;
using API.Settings;
using Core.Response;
using IIIF.Presentation.V3;
using Microsoft.AspNetCore.HttpOverrides;
using Models.API.Collection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Repository;
using Serilog;

Expand Down Expand Up @@ -44,7 +39,7 @@
builder.Services.AddCaching(cacheSettings);
builder.Services.AddSingleton<IETagManager, ETagManager>();
builder.Services.ConfigureMediatR();
builder.Services.AddSingleton<ETagManager>();
builder.Services.ConfigureIdGenerator();
builder.Services.AddHealthChecks();
builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
Expand Down

0 comments on commit 966675e

Please sign in to comment.