Skip to content

Commit

Permalink
Merge pull request #117 from dlcs/feature/validate_manifest_parent
Browse files Browse the repository at this point in the history
Validate hierarchical manifest parent
  • Loading branch information
donaldgray authored Oct 24, 2024
2 parents 28ff5e4 + 97570c8 commit fe2fa97
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using API.Converters;
using Models.API.Manifest;
using Models.Database.General;
using DBManifest = Models.Database.Collections.Manifest;

namespace API.Tests.Converters;
Expand All @@ -16,7 +17,8 @@ public void SetGeneratedFields_AddsCustomContext()
CustomerId = 123,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Id = "id"
Id = "id",
Hierarchy = [new Hierarchy { Slug = "slug" }],
};

var expectedContexts = new List<string>
Expand All @@ -42,7 +44,8 @@ public void SetGeneratedFields_SetsId()
CustomerId = 123,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Id = "id"
Id = "id",
Hierarchy = [new Hierarchy { Slug = "slug" }],
};

// Act
Expand All @@ -64,7 +67,8 @@ public void SetGeneratedFields_SetsAuditFields()
Modified = DateTime.UtcNow.AddDays(1),
CreatedBy = "creator",
ModifiedBy = "modifier",
Id = "id"
Id = "id",
Hierarchy = [new Hierarchy { Slug = "slug" }],
};

// Act
Expand All @@ -76,5 +80,63 @@ public void SetGeneratedFields_SetsAuditFields()

result.CreatedBy.Should().Be("creator");
result.ModifiedBy.Should().Be("modifier");
}

[Fact]
public void SetGeneratedFields_SetsParentAndSlug_FromSingleHierarchyByDefault()
{
// Arrange
var iiifManifest = new PresentationManifest
{
Parent = "parent-will-be-overriden",
Slug = "slug-will-be-overriden",
};
var dbManifest = new DBManifest
{
CustomerId = 123,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow.AddDays(1),
CreatedBy = "creator",
ModifiedBy = "modifier",
Id = "id",
Hierarchy = [new Hierarchy { Slug = "hierarchy-slug", Parent = "hierarchy-parent" }],
};

// Act
var result = iiifManifest.SetGeneratedFields(dbManifest, new UrlRoots());

// Assert
result.Slug.Should().Be("hierarchy-slug");
result.Parent.Should().Be("/0/collections/hierarchy-parent", "Always use FlatId");
}

[Fact]
public void SetGeneratedFields_SetsParentAndSlug_FromHierarchyUsingFactory()
{
// Arrange
var iiifManifest = new PresentationManifest
{
Parent = "parent-will-be-overriden",
Slug = "slug-will-be-overriden",
};
var dbManifest = new DBManifest
{
CustomerId = 123,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow.AddDays(1),
CreatedBy = "creator",
ModifiedBy = "modifier",
Id = "id",
Hierarchy = [
new Hierarchy { Slug = "hierarchy-slug", Parent = "hierarchy-parent" },
new Hierarchy { Slug = "other-slug", Parent = "other-parent" },],
};

// Act
var result = iiifManifest.SetGeneratedFields(dbManifest, new UrlRoots(), manifest => manifest.Hierarchy.Last());

// Assert
result.Slug.Should().Be("other-slug");
result.Parent.Should().Be("/0/collections/other-parent", "Always use FlatId");
}
}
21 changes: 2 additions & 19 deletions src/IIIFPresentation/API.Tests/Helpers/CollectionHelperXTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ public void GenerateFlatId_Correct_Manifest()
[Theory]
[InlineData(ResourceType.StorageCollection)]
[InlineData(ResourceType.IIIFCollection)]
public void GenerateFlatParentId_Correct_Collection(ResourceType resourceType)
[InlineData(ResourceType.IIIFManifest)]
public void GenerateFlatParentId_Correct(ResourceType resourceType)
{
// Arrange
var hierarchy = new Hierarchy
Expand All @@ -179,24 +180,6 @@ public void GenerateFlatParentId_Correct_Collection(ResourceType resourceType)
id.Should().Be("http://base/0/collections/parent");
}

[Fact]
public void GenerateFlatParentId_Correct_Manifest()
{
// Arrange
var hierarchy = new Hierarchy
{
Slug = "slug",
Parent = "parent",
Type = ResourceType.IIIFManifest
};

// Act
var id = hierarchy.GenerateFlatParentId(urlRoots);

// Assert
id.Should().Be("http://base/0/manifests/parent");
}

[Fact]
public void GenerateFlatCollectionViewId_CreatesViewId()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using API.Converters;
using API.Infrastructure.Validation;
using Models.API;
using Models.Database.Collections;

namespace API.Tests.Infrastructure.Validation;

public class PresentationValidationTests
{
private readonly UrlRoots urlRoots = new() { BaseUrl = "https://api.tests" };

[Fact]
public void IsUriParentInvalid_False_IfNotUri()
{
// Arrange
var presentation = new TestPresentation { Parent = "foo" };
var parent = new Collection { Id = "bar" };

// Assert
presentation.IsUriParentInvalid(parent, urlRoots).Should().BeFalse();
}

[Fact]
public void IsUriParentInvalid_False_IfUriAndMatchesParent()
{
// Arrange
var presentation = new TestPresentation { Parent = "https://api.tests/1/collections/parent" };
var parent = new Collection { Id = "parent", CustomerId = 1 };

// Assert
presentation.IsUriParentInvalid(parent, urlRoots).Should().BeFalse();
}

[Fact]
public void IsUriParentInvalid_True_IfUriAndDoesNotMatchParent()
{
// Arrange
var presentation = new TestPresentation { Parent = "https://api.tests/not-parent" };
var parent = new Collection { Id = "parent", CustomerId = 1 };

// Assert
presentation.IsUriParentInvalid(parent, urlRoots).Should().BeTrue();
}
}

public class TestPresentation : IPresentation
{
public string? Slug { get; set; }
public string? Parent { get; set; }
public DateTime Created { get; set; }
public DateTime Modified { get; set; }
public string? CreatedBy { get; set; }
public string? ModifiedBy { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Core.Response;
using IIIF.Serialisation;
using Microsoft.EntityFrameworkCore;
using Models.API.General;
using Models.API.Manifest;
using Models.Database.General;
using Models.Database.Collections;
Expand Down Expand Up @@ -263,6 +264,60 @@ public async Task CreateManifest_Conflict_IfParentAndSlugExist_ForManifest()
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}

[Fact]
public async Task CreateManifest_BadRequest_WhenParentIsInvalidHierarchicalUri()
{
// Arrange
var slug = nameof(CreateManifest_BadRequest_WhenParentIsInvalidHierarchicalUri);
var manifest = new PresentationManifest
{
Parent = "http://different.host/root",
Slug = slug
};

var requestMessage =
HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, $"{Customer}/manifests", manifest.AsJson());

// Act
var response = await httpClient.AsCustomer(1).SendAsync(requestMessage);
var error = await response.ReadAsPresentationResponseAsync<Error>();

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error!.Detail.Should().Be("The parent collection could not be found");
error.ErrorTypeUri.Should().Be("http://localhost/errors/ModifyCollectionType/ParentCollectionNotFound");
}

[Fact]
public async Task CreateManifest_CreatesManifest_ParentIsValidHierarchicalUrl()
{
// Arrange
var slug = nameof(CreateManifest_CreatesManifest_ParentIsValidHierarchicalUrl);
var manifest = new PresentationManifest
{
Parent = $"http://localhost/1/collections/{RootCollection.Id}",
Slug = slug,
};

var requestMessage =
HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, $"{Customer}/manifests", manifest.AsJson());

// Act
var response = await httpClient.AsCustomer(1).SendAsync(requestMessage);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);

var responseManifest = await response.ReadAsPresentationResponseAsync<PresentationManifest>();

responseManifest.Id.Should().NotBeNull();
responseManifest.Created.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2));
responseManifest.Modified.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2));
responseManifest.CreatedBy.Should().Be("Admin");
responseManifest.Slug.Should().Be(slug);
responseManifest.Parent.Should().Be($"http://localhost/1/collections/{RootCollection.Id}");
}

[Fact]
public async Task CreateManifest_ReturnsManifest()
{
Expand Down Expand Up @@ -290,7 +345,7 @@ public async Task CreateManifest_ReturnsManifest()
responseManifest.Modified.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2));
responseManifest.CreatedBy.Should().Be("Admin");
responseManifest.Slug.Should().Be(slug);
responseManifest.Parent.Should().Be(RootCollection.Id);
responseManifest.Parent.Should().Be($"http://localhost/1/collections/{RootCollection.Id}");
}

[Fact]
Expand Down
20 changes: 19 additions & 1 deletion src/IIIFPresentation/API/Converters/ManifestConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,37 @@
using IIIF;
using IIIF.Presentation;
using Models.API.Manifest;
using Models.Database.Collections;
using Models.Database.General;

namespace API.Converters;

public static class ManifestConverter
{
/// <summary>
/// Update <see cref="PresentationManifest"/> with values from DB record.
/// </summary>
/// <param name="iiifManifest">Presentation Manifest to update</param>
/// <param name="dbManifest">Database Manifest</param>
/// <param name="urlRoots">Current UrlRoots instance</param>
/// <param name="hierarchyFactory">
/// Optional factory to specify <see cref="Hierarchy"/> to use to get Parent and Slug. Defaults to using .Single()
/// </param>
/// <returns></returns>
public static PresentationManifest SetGeneratedFields(this PresentationManifest iiifManifest,
Models.Database.Collections.Manifest dbManifest, UrlRoots urlRoots)
Manifest dbManifest, UrlRoots urlRoots, Func<Manifest, Hierarchy>? hierarchyFactory = null)
{
hierarchyFactory ??= manifest => manifest.Hierarchy.ThrowIfNull(nameof(manifest.Hierarchy)).Single();

var hierarchy = hierarchyFactory(dbManifest);

iiifManifest.Id = dbManifest.GenerateFlatManifestId(urlRoots);
iiifManifest.Created = dbManifest.Created.Floor(DateTimeX.Precision.Second);
iiifManifest.Modified = dbManifest.Modified.Floor(DateTimeX.Precision.Second);
iiifManifest.CreatedBy = dbManifest.CreatedBy;
iiifManifest.ModifiedBy = dbManifest.ModifiedBy;
iiifManifest.Parent = hierarchy.GenerateFlatParentId(urlRoots);
iiifManifest.Slug = hierarchy.Slug;
iiifManifest.EnsurePresentation3Context();
iiifManifest.EnsureContext(PresentationJsonLdContext.Context);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using API.Infrastructure.AWS;
using API.Infrastructure.IdGenerator;
using API.Infrastructure.Requests;
using API.Infrastructure.Validation;
using Core;
using IIIF.Serialisation;
using MediatR;
Expand Down Expand Up @@ -47,7 +48,7 @@ public async Task<ModifyEntityResult<PresentationManifest, ModifyCollectionType>
var parentCollection = await dbContext.Collections.Retrieve(request.CustomerId,
request.PresentationManifest.GetParentSlug(), cancellationToken: cancellationToken);

var parentErrors = ValidateParent(parentCollection);
var parentErrors = ValidateParent(parentCollection, request.PresentationManifest, request.UrlRoots);
if (parentErrors != null) return parentErrors;

var (error, dbManifest) = await UpdateDatabase(request, parentCollection!, cancellationToken);
Expand All @@ -59,13 +60,14 @@ public async Task<ModifyEntityResult<PresentationManifest, ModifyCollectionType>
request.PresentationManifest.SetGeneratedFields(dbManifest!, request.UrlRoots), WriteResult.Created);
}

private static ModifyEntityResult<PresentationManifest, ModifyCollectionType>? ValidateParent(Collection? parentCollection)
private static ModifyEntityResult<PresentationManifest, ModifyCollectionType>? ValidateParent(
Collection? parentCollection, PresentationManifest manifest, UrlRoots urlRoots)
{
if (parentCollection == null) return ErrorHelper.NullParentResponse<PresentationManifest>();
if (!parentCollection.IsStorageCollection) return ManifestErrorHelper.ParentMustBeStorageCollection<PresentationManifest>();
if (manifest.IsUriParentInvalid(parentCollection, urlRoots)) return ErrorHelper.NullParentResponse<PresentationManifest>();

return parentCollection.IsStorageCollection
? null
: ManifestErrorHelper.ParentMustBeStorageCollection<PresentationManifest>();
return null;
}

private async Task<(ModifyEntityResult<PresentationManifest, ModifyCollectionType>?, DbManifest?)> UpdateDatabase(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using API.Helpers;
using API.Infrastructure.AWS;
using API.Infrastructure.Requests;
using API.Infrastructure.Validation;
using API.Settings;
using Core;
using Core.Helpers;
Expand Down Expand Up @@ -62,8 +63,7 @@ public async Task<ModifyEntityResult<PresentationCollection, ModifyCollectionTyp
if (parentCollection == null) return ErrorHelper.NullParentResponse<PresentationCollection>();

// If full URI was used, verify it indeed is pointing to the resolved parent collection
if (Uri.IsWellFormedUriString(request.Collection.Parent, UriKind.Absolute)
&& !parentCollection.GenerateFlatCollectionId(request.UrlRoots).Equals(request.Collection.Parent))
if (request.Collection.IsUriParentInvalid(parentCollection, request.UrlRoots))
return ErrorHelper.NullParentResponse<PresentationCollection>();

string id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using API.Infrastructure.AWS;
using API.Infrastructure.Helpers;
using API.Infrastructure.Requests;
using API.Infrastructure.Validation;
using API.Settings;
using Core;
using Core.Exceptions;
Expand Down Expand Up @@ -82,8 +83,7 @@ public async Task<ModifyEntityResult<PresentationCollection, ModifyCollectionTyp

if (parentCollection == null) return ErrorHelper.NullParentResponse<PresentationCollection>();
// If full URI was used, verify it indeed is pointing to the resolved parent collection
if (Uri.IsWellFormedUriString(request.Collection.Parent, UriKind.Absolute)
&& !parentCollection.GenerateFlatCollectionId(request.UrlRoots).Equals(request.Collection.Parent))
if (request.Collection.IsUriParentInvalid(parentCollection, request.UrlRoots))
return ErrorHelper.NullParentResponse<PresentationCollection>();

databaseCollection = new Collection
Expand Down Expand Up @@ -145,8 +145,7 @@ public async Task<ModifyEntityResult<PresentationCollection, ModifyCollectionTyp
if (parentCollection == null) return ErrorHelper.NullParentResponse<PresentationCollection>();

// If full URI was used, verify it indeed is pointing to the resolved parent collection
if (Uri.IsWellFormedUriString(request.Collection.Parent, UriKind.Absolute)
&& !parentCollection.GenerateFlatCollectionId(request.UrlRoots).Equals(request.Collection.Parent))
if (request.Collection.IsUriParentInvalid(parentCollection, request.UrlRoots))
return ErrorHelper.NullParentResponse<PresentationCollection>();

parentId = parentCollection.Id;
Expand Down
Loading

0 comments on commit fe2fa97

Please sign in to comment.