Skip to content

Commit 3ccd5bd

Browse files
authored
Merge pull request #1089 from dlcs/feature/output_external_adjunct
Output external adjunct on Manifests
2 parents 258a962 + 4c93801 commit 3ccd5bd

File tree

12 files changed

+201
-39
lines changed

12 files changed

+201
-39
lines changed

src/protagonist/DLCS.Model/Assets/Adjunct.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ public class Adjunct
7272
public Asset Asset { get; set; } = null!;
7373
}
7474

75+
/// <summary>
76+
/// The type of linking property to use for adjunct. Determines how this is output on Manifest.
77+
/// </summary>
7578
public enum IIIFLinkType
7679
{
7780
[Description("seeAlso")]

src/protagonist/Orchestrator.Tests/Infrastructure/AssetQueryableXTests.cs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,28 @@ public AssetQueryableXTests(DlcsDatabaseFixture dbFixture)
2424
}
2525

2626
[Fact]
27-
public async Task IncludeRelevantMetadata_HandlesNoRelatedData()
27+
public async Task IncludeRelationsForProjections_HandlesNoRelatedData()
2828
{
2929
var assetId = AssetIdGenerator.GetAssetId();
3030
await dbContext.Images.AddTestAsset(assetId);
3131
await dbContext.SaveChangesAsync();
3232

33-
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelevantMetadata().SingleAsync();
33+
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelationsForProjections().SingleAsync();
3434

3535
asset.Should().NotBeNull("Asset returned despite having no related data");
3636
asset.AssetApplicationMetadata.Should().BeNullOrEmpty();
3737
asset.ImageDeliveryChannels.Should().BeNullOrEmpty();
38+
asset.Adjuncts.Should().BeNullOrEmpty();
3839
}
3940

4041
[Fact]
41-
public async Task IncludeRelevantMetadata_ReturnsRelatedThumbs()
42+
public async Task IncludeRelationsForProjections_ReturnsRelatedThumbs()
4243
{
4344
var assetId = AssetIdGenerator.GetAssetId();
4445
await dbContext.Images.AddTestAsset(assetId).WithTestThumbnailMetadata().WithTestDeliveryChannel("iiif-img");
4546
await dbContext.SaveChangesAsync();
4647

47-
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelevantMetadata().SingleAsync();
48+
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelationsForProjections().SingleAsync();
4849

4950
asset.Should().NotBeNull();
5051
asset.AssetApplicationMetadata.Should().HaveCount(1)
@@ -53,7 +54,7 @@ public async Task IncludeRelevantMetadata_ReturnsRelatedThumbs()
5354
}
5455

5556
[Fact]
56-
public async Task IncludeRelevantMetadata_ReturnsRelatedTranscodes()
57+
public async Task IncludeRelationsForProjections_ReturnsRelatedTranscodes()
5758
{
5859
var assetId = AssetIdGenerator.GetAssetId();
5960
var fakeTranscodes = new List<AVTranscode>
@@ -63,15 +64,15 @@ public async Task IncludeRelevantMetadata_ReturnsRelatedTranscodes()
6364
(await dbContext.Images.AddTestAsset(assetId)).Entity.WithTestTranscodeMetadata(fakeTranscodes);
6465
await dbContext.SaveChangesAsync();
6566

66-
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelevantMetadata().SingleAsync();
67+
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelationsForProjections().SingleAsync();
6768

6869
asset.Should().NotBeNull();
6970
asset.AssetApplicationMetadata.Should().HaveCount(1)
7071
.And.Subject.Single().MetadataType.Should().Be("AVTranscodes");
7172
}
7273

7374
[Fact]
74-
public async Task IncludeRelevantMetadata_ReturnsThumbsAndTranscodes()
75+
public async Task IncludeRelationsForProjections_ReturnsThumbsAndTranscodes()
7576
{
7677
var assetId = AssetIdGenerator.GetAssetId();
7778
var fakeTranscodes = new List<AVTranscode>
@@ -83,7 +84,7 @@ public async Task IncludeRelevantMetadata_ReturnsThumbsAndTranscodes()
8384
await dbContext.SaveChangesAsync();
8485

8586
var asset = await dbContext.Images.Where(i => i.Id == assetId)
86-
.IncludeRelevantMetadata()
87+
.IncludeRelationsForProjections()
8788
.SingleAsync();
8889

8990
asset.Should().NotBeNull();
@@ -92,5 +93,23 @@ public async Task IncludeRelevantMetadata_ReturnsThumbsAndTranscodes()
9293
asset.AssetApplicationMetadata.Should().ContainSingle(m => m.MetadataType == "AVTranscodes");
9394
asset.ImageDeliveryChannels.Should().HaveCount(1);
9495
}
96+
97+
[Fact]
98+
public async Task IncludeRelationsForProjections_ReturnsAdjuncts()
99+
{
100+
var assetId = AssetIdGenerator.GetAssetId();
101+
const string adjuntId = "ekki mukk";
102+
await dbContext.Images
103+
.AddTestAsset(assetId)
104+
.WithTestThumbnailMetadata()
105+
.WithTestDeliveryChannel("iiif-img")
106+
.WithTestAdjunct(adjuntId);
107+
await dbContext.SaveChangesAsync();
108+
109+
var asset = await dbContext.Images.Where(i => i.Id == assetId).IncludeRelationsForProjections().SingleAsync();
110+
111+
asset.Should().NotBeNull();
112+
asset.Adjuncts.Should().ContainSingle(a => a.Id == adjuntId);
113+
}
95114
}
96115

src/protagonist/Orchestrator.Tests/Integration/ManifestHandlingTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using IIIF.ImageApi.V3;
1010
using IIIF.Presentation.V3.Annotation;
1111
using IIIF.Presentation.V3.Content;
12+
using IIIF.Presentation.V3.Strings;
1213
using IIIF.Serialisation;
1314
using Microsoft.Extensions.DependencyInjection;
1415
using Newtonsoft.Json.Linq;
@@ -794,4 +795,66 @@ await dbFixture.DbContext.Images.AddTestAsset(id, roles: "clickthrough", maxUnau
794795
.ContainSingle(s => s is AuthProbeService2 && s.Id.Contains(id.ToString()));
795796
paintable.Service.OfType<AuthProbeService2>().Single().Id.Should().Contain(id.ToString());
796797
}
798+
799+
[Fact]
800+
public async Task Get_V3ManifestWithAdjuncts_OutputsAdjunctsInCorrectLocation()
801+
{
802+
// Arrange
803+
var id = AssetIdGenerator.GetAssetId();
804+
await dbFixture.DbContext.Images
805+
.AddTestAsset(id, imageDeliveryChannels: imageDeliveryChannels)
806+
.WithTestAdjunct("seeAlso1", type: "Dataset", mediaType: "text/xml", iiifLinkType: IIIFLinkType.SeeAlso,
807+
profile: "http://www.loc.gov/standards/alto/v3/alto.xsd", label: new LanguageMap("en", "METS-ALTO XML"),
808+
externalId: "https://mets.example/1", language: ["en-GB"])
809+
.WithTestAdjunct("anno1", type: "Ignored", mediaType: "text/ignored",
810+
iiifLinkType: IIIFLinkType.Annotations, label: new LanguageMap("en", "Line-level annos"),
811+
externalId: "https://mets.example/w3c", language: ["ignored"])
812+
.WithTestAdjunct("rendering1", type: "Text", mediaType: "application/pdf", iiifLinkType: IIIFLinkType.Rendering,
813+
label: new LanguageMap("none", "PDF of image"), externalId: "https://pdf.example/1", language: ["fr"])
814+
.WithTestAdjunct("seeAlso2", type: "Text", mediaType: "text/xml", iiifLinkType: IIIFLinkType.SeeAlso,
815+
externalId: "https://other.example/2");
816+
817+
await dbFixture.DbContext.SaveChangesAsync();
818+
819+
List<AnnotationPage> expectedAnnos =
820+
[
821+
new() { Id = "https://mets.example/w3c", Label = new LanguageMap("en", "Line-level annos") }
822+
];
823+
824+
List<ExternalResource> expectedSeeAlso =
825+
[
826+
new("Dataset")
827+
{
828+
Id = "https://mets.example/1", Label = new LanguageMap("en", "METS-ALTO XML"),
829+
Profile = "http://www.loc.gov/standards/alto/v3/alto.xsd", Format = "text/xml",
830+
Language = ["en-GB"]
831+
},
832+
new("Text")
833+
{
834+
Id = "https://other.example/2", Format = "text/xml"
835+
},
836+
];
837+
838+
List<ExternalResource> expectedRendering =
839+
[
840+
new("Text")
841+
{
842+
Id = "https://pdf.example/1", Label = new LanguageMap("none", "PDF of image"),
843+
Format = "application/pdf", Language = ["fr"]
844+
},
845+
];
846+
847+
var path = $"iiif-manifest/v3/{id}";
848+
849+
// Act
850+
var response = await httpClient.GetAsync(path);
851+
852+
// Assert
853+
var manifest = (await response.Content.ReadAsStreamAsync()).FromJsonStream<IIIF3.Manifest>();
854+
var canvas = manifest.Items!.Single();
855+
856+
canvas.SeeAlso.Should().BeEquivalentTo(expectedSeeAlso);
857+
canvas.Rendering.Should().BeEquivalentTo(expectedRendering);
858+
canvas.Annotations.Should().BeEquivalentTo(expectedAnnos);
859+
}
797860
}

src/protagonist/Orchestrator.Tests/Integration/NamedQueryTests.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ public NamedQueryTests(ProtagonistAppFactory<Startup> factory, DlcsDatabaseFixtu
5757
dbFixture.DbContext.Images.AddTestAsset(AssetId.FromString("99/1/matching-nothumbs"), num1: 3, ref1: "my-ref",
5858
maxUnauthorised: 10, roles: "default")
5959
.WithTestThumbnailMetadata()
60-
.WithTestDeliveryChannel(AssetDeliveryChannels.Image);
60+
.WithTestDeliveryChannel(AssetDeliveryChannels.Image)
61+
.WithTestAdjunct("test-adjunct", externalId: "https://example.com/see-also-1");
6162
dbFixture.DbContext.Images.AddTestAsset(AssetId.FromString("99/1/not-for-delivery"), num1: 4, ref1: "my-ref",
6263
notForDelivery: true)
6364
.WithTestThumbnailMetadata()
@@ -373,6 +374,41 @@ await dbFixture.DbContext.Images.AddTestAsset(AssetId.FromString("99/1/second"),
373374
}
374375
}
375376

377+
[Fact]
378+
public async Task Get_ReturnsV3ManifestWithCorrectAdjuncts()
379+
{
380+
// Arrange
381+
const string path = "iiif-resource/v3/99/test-named-query/my-ref/1";
382+
const string iiif3 = "application/ld+json; profile=\"http://iiif.io/api/presentation/3/context.json\"";
383+
384+
// Act
385+
var response = await httpClient.GetAsync(path);
386+
387+
// Assert
388+
response.StatusCode.Should().Be(HttpStatusCode.OK);
389+
response.Headers.Vary.Should().Contain("Accept");
390+
response.Content.Headers.ContentType.ToString().Should().Be(iiif3);
391+
392+
var manifest = (await response.Content.ReadAsStreamAsync()).FromJsonStream<IIIF3.Manifest>();
393+
manifest.Items.Should().HaveCount(3);
394+
395+
bool checkedSeeAlso = false;
396+
foreach (var canvas in manifest.Items!)
397+
{
398+
if (canvas.Id.Contains("matching-nothumbs"))
399+
{
400+
checkedSeeAlso = true;
401+
canvas.SeeAlso.Should().ContainSingle(sa => sa.Id == "https://example.com/see-also-1");
402+
}
403+
else
404+
{
405+
canvas.SeeAlso.Should().BeNull();
406+
}
407+
}
408+
409+
checkedSeeAlso.Should().BeTrue("No seeAlso checked in test, verify test data");
410+
}
411+
376412
[Fact]
377413
public async Task Get_AssetsRequireAuth_ReturnsV2ManifestWithoutAuthServices()
378414
{

src/protagonist/Orchestrator/Features/Manifests/IIIFNamedQueryProjector.cs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using IIIF;
1111
using Microsoft.AspNetCore.Http;
1212
using Microsoft.EntityFrameworkCore;
13+
using Microsoft.Extensions.Logging;
1314
using Orchestrator.Infrastructure;
1415
using Orchestrator.Infrastructure.IIIF;
1516
using Orchestrator.Infrastructure.IIIF.Manifests;
@@ -21,15 +22,8 @@ namespace Orchestrator.Features.Manifests;
2122
/// <summary>
2223
/// Methods for generating IIIF results from NamedQueries
2324
/// </summary>
24-
public class IIIFNamedQueryProjector
25+
public class IIIFNamedQueryProjector(IIIFManifestBuilder manifestBuilder, ILogger<IIIFNamedQueryProjector> logger)
2526
{
26-
private readonly IIIFManifestBuilder manifestBuilder;
27-
28-
public IIIFNamedQueryProjector(IIIFManifestBuilder manifestBuilder)
29-
{
30-
this.manifestBuilder = manifestBuilder;
31-
}
32-
3327
/// <summary>
3428
/// Project NamedQueryResult to IIIF presentation object
3529
/// </summary>
@@ -40,10 +34,14 @@ public IIIFNamedQueryProjector(IIIFManifestBuilder manifestBuilder)
4034
var parsedNamedQuery = namedQueryResult.ParsedQuery.ThrowIfNull(nameof(request.Query))!;
4135

4236
var assets = await namedQueryResult.Results
43-
.IncludeRelevantMetadata()
37+
.IncludeRelationsForProjections()
4438
.AsSplitQuery()
4539
.ToListAsync(cancellationToken);
46-
if (assets.Count == 0) return null;
40+
if (assets.Count == 0)
41+
{
42+
logger.LogDebug("No assets found that match NQ parameters");
43+
return null;
44+
}
4745

4846
var orderedImages = NamedQueryProjections.GetOrderedAssets(assets, parsedNamedQuery).ToList();
4947

src/protagonist/Orchestrator/Features/Manifests/Requests/GetManifestForAsset.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public async Task<DescriptionResourceResponse> Handle(GetManifestForAsset reques
6363
var assetId = request.AssetRequest.GetAssetId();
6464

6565
var asset = await dlcsContext.Images
66-
.IncludeRelevantMetadata()
66+
.IncludeRelationsForProjections()
6767
.FirstOrDefaultAsync(a => a.Id == assetId, cancellationToken);
6868

6969
if (asset == null || asset.NotForDelivery)

src/protagonist/Orchestrator/Infrastructure/AssetQueryableX.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@ namespace Orchestrator.Infrastructure;
88
public static class AssetQueryableX
99
{
1010
/// <summary>
11-
/// Includes data from <see cref="AssetApplicationMetadata"/> and related <see cref="ImageDeliveryChannel"/> that is
12-
/// relevant to Orchestrator processing for manifests and named query projections
11+
/// Includes data from <see cref="AssetApplicationMetadata"/> and related <see cref="ImageDeliveryChannel"/> and
12+
/// <see cref="Adjunct"/> that are relevant to Orchestrator processing manifests and named query projections
1313
/// </summary>
14-
public static IQueryable<Asset> IncludeRelevantMetadata(this IQueryable<Asset> assets) =>
14+
/// <remarks>
15+
/// This currently only returns adjuncts with an ExternalId, which is currently ALL adjuncts. Future changes will
16+
/// make ExternalId nullable, so this clause will prevent those appearing in Manifests until we want them. It can
17+
/// be removed when DLCS hosted assets are available.
18+
/// </remarks>
19+
public static IQueryable<Asset> IncludeRelationsForProjections(this IQueryable<Asset> assets) =>
1520
assets.Include(a =>
1621
a.AssetApplicationMetadata.Where(md =>
1722
md.MetadataType == AssetApplicationMetadataTypes.ThumbSizes ||
1823
md.MetadataType == AssetApplicationMetadataTypes.AVTranscodes))
19-
.Include(a => a.ImageDeliveryChannels);
24+
.Include(a => a.ImageDeliveryChannels)
25+
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
26+
.Include(a => a.Adjuncts!.Where(adj => adj.ExternalId != null));
2027
}

src/protagonist/Orchestrator/Infrastructure/IIIF/IIIFManifestBuilder.cs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,10 @@ namespace Orchestrator.Infrastructure.IIIF;
1212
/// <summary>
1313
/// Class for creating IIIF Manifests from provided assets
1414
/// </summary>
15-
public class IIIFManifestBuilder
15+
public class IIIFManifestBuilder(
16+
IBuildManifests<IIIF3.Manifest> manifestV3Builder,
17+
IBuildManifests<IIIF2.Manifest> manifestV2Builder)
1618
{
17-
private readonly IBuildManifests<IIIF3.Manifest> manifestV3Builder;
18-
private readonly IBuildManifests<IIIF2.Manifest> manifestV2Builder;
19-
20-
public IIIFManifestBuilder(IBuildManifests<IIIF3.Manifest> manifestV3Builder,
21-
IBuildManifests<IIIF2.Manifest> manifestV2Builder)
22-
{
23-
this.manifestV3Builder = manifestV3Builder;
24-
this.manifestV2Builder = manifestV2Builder;
25-
}
26-
2719
public Task<IIIF3.Manifest> GenerateV3Manifest(List<Asset> assets, CustomerPathElement customerPathElement,
2820
string manifestId, string label, ManifestType manifestType, CancellationToken cancellationToken)
2921
=> manifestV3Builder.BuildManifest(manifestId, label, assets, customerPathElement, manifestType,

src/protagonist/Orchestrator/Infrastructure/IIIF/Manifests/ManifestV3Builder.cs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ private async Task<AssetCanvas> GetCanvasForAsset(Asset asset, CustomerPathEleme
209209
{
210210
canvas.Thumbnail = thumbnail.AsListOf<ExternalResource>();
211211
}
212+
213+
AddAdjunctsToCanvas(canvas, asset);
212214

213215
return new AssetCanvas(canvas, additionalContexts);
214216
}
@@ -268,7 +270,7 @@ private async Task<AssetCanvas> GetCanvasForAsset(Asset asset, CustomerPathEleme
268270
{
269271
logger.LogTrace("{CanvasId} is timebased, processing", canvas.Id);
270272
var canvasId = canvas.Id;
271-
var transcodes = asset.AssetApplicationMetadata.GetTranscodeMetadata(false);
273+
var transcodes = asset.AssetApplicationMetadata.GetTranscodeMetadata();
272274

273275
if (transcodes.IsNullOrEmpty())
274276
{
@@ -481,4 +483,46 @@ private static List<IService> GetDistinctAccessServices(Dictionary<AssetId, Auth
481483
.ToList();
482484
return accessServices;
483485
}
486+
487+
private void AddAdjunctsToCanvas(Canvas canvas, Asset asset)
488+
{
489+
var adjuncts = asset.Adjuncts ?? Enumerable.Empty<Adjunct>();
490+
491+
foreach (var adjunct in adjuncts)
492+
{
493+
logger.LogTrace("Adding adjunct {AdjunctId} to {CanvasId}", adjunct.Id, canvas.Id);
494+
switch (adjunct.IIIFLink)
495+
{
496+
case IIIFLinkType.SeeAlso:
497+
canvas.SeeAlso ??= [];
498+
canvas.SeeAlso.Add(CreateExternalResource(adjunct));
499+
break;
500+
case IIIFLinkType.Annotations:
501+
canvas.Annotations ??= [];
502+
canvas.Annotations.Add(new AnnotationPage
503+
{
504+
Id = adjunct.ExternalId.ToString(),
505+
Label = adjunct.Label,
506+
});
507+
break;
508+
case IIIFLinkType.Rendering:
509+
canvas.Rendering ??= [];
510+
canvas.Rendering.Add(CreateExternalResource(adjunct));
511+
break;
512+
default:
513+
throw new ArgumentOutOfRangeException(nameof(adjunct.IIIFLink), adjunct.IIIFLink,
514+
"IIIFLink type not supported");
515+
}
516+
}
517+
518+
// "rendering" and "seeAlso" are ExternalResources
519+
ExternalResource CreateExternalResource(Adjunct adjunct) => new(adjunct.Type)
520+
{
521+
Id = adjunct.ExternalId.ToString(),
522+
Format = adjunct.MediaType,
523+
Profile = adjunct.Profile,
524+
Label = adjunct.Label,
525+
Language = adjunct.Language?.ToList(),
526+
};
527+
}
484528
}

0 commit comments

Comments
 (0)