diff --git a/src/IIIF/IIIF.Tests/Serialisation/DiscoveryTests.cs b/src/IIIF/IIIF.Tests/Serialisation/DiscoveryTests.cs new file mode 100644 index 0000000..0194d4c --- /dev/null +++ b/src/IIIF/IIIF.Tests/Serialisation/DiscoveryTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using IIIF.Discovery.V1; +using IIIF.Presentation; +using IIIF.Presentation.V3.Content; +using IIIF.Presentation.V3.Strings; +using IIIF.Serialisation; +using Xunit; + +namespace IIIF.Tests.Serialisation +{ + public class DiscoveryTests + { + [Fact] + public void OrderedCollection() + { + // Arrange + // Example from https://iiif.io/api/discovery/1.0/#complete-ordered-collection-example + var expected = @"{ + ""@context"": ""http://iiif.io/api/discovery/1/context.json"", + ""id"": ""https://example.org/activity/all-changes"", + ""type"": ""OrderedCollection"", + ""totalItems"": 21456, + ""rights"": ""http://creativecommons.org/licenses/by/4.0/"", + ""seeAlso"": [ + { + ""id"": ""https://example.org/dataset/all-dcat.jsonld"", + ""type"": ""Dataset"", + ""profile"": ""http://www.w3.org/ns/dcat#"", + ""label"": {""en"":[""DCAT description of Collection""]}, + ""format"": ""application/ld+json"" + } + ], + ""partOf"": [ + { + ""id"": ""https://example.org/aggregated-changes"", + ""type"": ""OrderedCollection"" + } + ], + ""first"": { + ""id"": ""https://example.org/activity/page-0"", + ""type"": ""OrderedCollectionPage"" + }, + ""last"": { + ""id"": ""https://example.org/activity/page-214"", + ""type"": ""OrderedCollectionPage"" + } +}"; + var orderedCollection = new OrderedCollection + { + Id = "https://example.org/activity/all-changes", + TotalItems = 21456, + Rights = "http://creativecommons.org/licenses/by/4.0/", + SeeAlso = new List + { + new("Dataset") + { + Id = "https://example.org/dataset/all-dcat.jsonld", + Label = new LanguageMap("en", "DCAT description of Collection"), + Format = "application/ld+json", + Profile = "http://www.w3.org/ns/dcat#" + } + }, + PartOf = new List + { + new() { Id = "https://example.org/aggregated-changes" } + }, + First = new OrderedCollectionPage { Id = "https://example.org/activity/page-0" }, + Last = new OrderedCollectionPage { Id = "https://example.org/activity/page-214" }, + }; + orderedCollection.EnsureContext(Discovery.Context.ChangeDiscovery1Context); + + // Act + var json = orderedCollection.AsJson(); + + // Assert + json.ShouldMatchJson(expected); + } + + [Fact] + public void OrderedCollectionPage() + { + // Arrange + // Example from https://iiif.io/api/discovery/1.0/#complete-ordered-collection-page-example + var expected = @"{ + ""@context"": ""http://iiif.io/api/discovery/1/context.json"", + ""id"": ""https://example.org/activity/page-1"", + ""type"": ""OrderedCollectionPage"", + ""startIndex"": 20, + ""partOf"": { + ""id"": ""https://example.org/activity/all-changes"", + ""type"": ""OrderedCollection"" + }, + ""prev"": { + ""id"": ""https://example.org/activity/page-0"", + ""type"": ""OrderedCollectionPage"" + }, + ""next"": { + ""id"": ""https://example.org/activity/page-2"", + ""type"": ""OrderedCollectionPage"" + }, + ""orderedItems"": [ + { + ""type"": ""Update"", + ""object"": { + ""id"": ""https://example.org/iiif/1/manifest"", + ""type"": ""Manifest"" + }, + ""endTime"": ""2018-03-10T10:00:00"" + } + ] +}"; + var orderedCollectionPage = new OrderedCollectionPage + { + Id = "https://example.org/activity/page-1", + StartIndex = 20, + PartOf = new OrderedCollection { Id = "https://example.org/activity/all-changes" }, + Prev = new OrderedCollectionPage { Id = "https://example.org/activity/page-0" }, + Next = new OrderedCollectionPage { Id = "https://example.org/activity/page-2" }, + OrderedItems = new List + { + new() + { + Type = ActivityType.Update, + Object = new ActivityObject + { + Id = "https://example.org/iiif/1/manifest", + Type = "Manifest", + }, + EndTime = new DateTime(2018, 3, 10, 10, 0, 0) + } + } + }; + orderedCollectionPage.EnsureContext(Discovery.Context.ChangeDiscovery1Context); + + // Act + var json = orderedCollectionPage.AsJson(); + + // Assert + json.ShouldMatchJson(expected); + } + + [Fact] + public void Activity() + { + // Arrange + // Example from https://iiif.io/api/discovery/1.0/#complete-activity-example + var expected = @"{ + ""@context"": ""http://iiif.io/api/discovery/1/context.json"", + ""id"": ""https://example.org/activity/1"", + ""type"": ""Update"", + ""summary"": ""admin updated the manifest, fixing reported bug #15."", + ""object"": { + ""id"": ""https://example.org/iiif/1/manifest"", + ""type"": ""Manifest"", + ""canonical"": ""https://example.org/iiif/1"", + ""seeAlso"": [ + { + ""id"": ""https://example.org/dataset/single-item.jsonld"", + ""type"": ""Dataset"", + ""format"": ""application/ld+json"" + } + ] + }, + ""endTime"": ""2017-09-21T00:00:00"", + ""startTime"": ""2017-09-20T23:58:00"", + ""actor"": { + ""id"": ""https://example.org/person/admin1"", + ""type"": ""Person"" + } +}"; + var activity = new Activity + { + Id = "https://example.org/activity/1", + Type = ActivityType.Update, + Summary = "admin updated the manifest, fixing reported bug #15.", + Object = new ActivityObject + { + Id = "https://example.org/iiif/1/manifest", + Type = "Manifest", + Canonical = "https://example.org/iiif/1", + SeeAlso = new List + { + new("Dataset") + { + Id = "https://example.org/dataset/single-item.jsonld", + Format = "application/ld+json" + } + } + }, + StartTime = new DateTime(2017, 9, 20, 23, 58, 0), + EndTime = new DateTime(2017, 9, 21, 0, 0, 0), + Actor = new Actor { Id = "https://example.org/person/admin1", Type = ActorType.Person } + }; + + activity.EnsureContext(Discovery.Context.ChangeDiscovery1Context); + + // Act + var json = activity.AsJson(); + + // Assert + json.ShouldMatchJson(expected); + } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF.Tests/Serialisation/XsdDateTimeConverterTests.cs b/src/IIIF/IIIF.Tests/Serialisation/XsdDateTimeConverterTests.cs new file mode 100644 index 0000000..79b3a2b --- /dev/null +++ b/src/IIIF/IIIF.Tests/Serialisation/XsdDateTimeConverterTests.cs @@ -0,0 +1,55 @@ +using System; +using FluentAssertions; +using IIIF.Serialisation; +using Newtonsoft.Json; +using Xunit; + +namespace IIIF.Tests.Serialisation +{ + public class XsdDateTimeConverterTests + { + private readonly XsdDateTimeConverter sut = new(); + + [Fact] + public void Convert_UtcDate_Success() + { + // Arrange + var date = new DateTime(2023, 3, 3, 11, 08, 37); + var utcDate = DateTime.SpecifyKind(date, DateTimeKind.Utc); + + // Act + var result = JsonConvert.SerializeObject(utcDate, Formatting.None, sut); + + // Assert + result.Should().Be("\"2023-03-03T11:08:37Z\""); + } + + [Fact] + public void Convert_LocalDate_Success() + { + // Arrange + var date = new DateTime(2023, 3, 3, 11, 08, 37); + var localDate = DateTime.SpecifyKind(date, DateTimeKind.Local); + + // Act + var result = JsonConvert.SerializeObject(localDate, Formatting.None, sut); + + // Assert + result.Should().Be("\"2023-03-03T11:08:37+00:00\""); + } + + [Fact] + public void Convert_UnspecifiedDate_Success() + { + // Arrange + var date = new DateTime(2023, 3, 3, 11, 08, 37); + var unspecifiedDate = DateTime.SpecifyKind(date, DateTimeKind.Unspecified); + + // Act + var result = JsonConvert.SerializeObject(unspecifiedDate, Formatting.None, sut); + + // Assert + result.Should().Be("\"2023-03-03T11:08:37\""); + } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF.Tests/StringAssertionX.cs b/src/IIIF/IIIF.Tests/StringAssertionX.cs new file mode 100644 index 0000000..91423bc --- /dev/null +++ b/src/IIIF/IIIF.Tests/StringAssertionX.cs @@ -0,0 +1,16 @@ +using FluentAssertions; + +namespace IIIF.Tests +{ + public static class StringAssertionX + { + /// + /// Uses .Should().Be() fluent assertion but handles possible line ending differences + /// + public static void ShouldMatchJson(this string json, string expected, string because = "", + params object[] becauseArgs) + { + json.Replace("\r\n", "\n").Should().Be(expected.Replace("\r\n", "\n"), because, becauseArgs); + } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Discovery/ContentTypes.cs b/src/IIIF/IIIF/Discovery/ContentTypes.cs new file mode 100644 index 0000000..130e15e --- /dev/null +++ b/src/IIIF/IIIF/Discovery/ContentTypes.cs @@ -0,0 +1,13 @@ +namespace IIIF.Discovery +{ + /// + /// Contains Content-Type/Accepts headers for IIIF Discovery API. + /// + public static class ContentTypes + { + /// + /// Content-Type for change discovery v1. + /// + public const string V1 = "application/ld+json;profile=\"" + Context.ChangeDiscovery1Context + "\""; + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Discovery/Context.cs b/src/IIIF/IIIF/Discovery/Context.cs new file mode 100644 index 0000000..9070a18 --- /dev/null +++ b/src/IIIF/IIIF/Discovery/Context.cs @@ -0,0 +1,7 @@ +namespace IIIF.Discovery +{ + public class Context + { + public const string ChangeDiscovery1Context = "http://iiif.io/api/discovery/1/context.json"; + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Discovery/V1/Activity.cs b/src/IIIF/IIIF/Discovery/V1/Activity.cs new file mode 100644 index 0000000..c4f9dbf --- /dev/null +++ b/src/IIIF/IIIF/Discovery/V1/Activity.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using IIIF.Presentation.V3; +using IIIF.Presentation.V3.Content; +using IIIF.Serialisation; +using Newtonsoft.Json; + +namespace IIIF.Discovery.V1 +{ + /// + /// The Activities are the means of describing the changes that have occurred in the content provider’s system. + /// + /// See https://iiif.io/api/discovery/1.0/#activities + public class Activity : JsonLdBase + { + [JsonProperty(Order = 2)] + public string? Id { get; set; } + + [JsonProperty(Order = 3)] + [EnumAsString] + public ActivityType Type { get; set; } + + /// + /// A short textual description of the Activity + /// + [JsonProperty(Order = 5)] + public string? Summary { get; set; } + + /// + /// The IIIF resource that was affected by the Activity + /// + [JsonProperty(Order = 6)] + public ActivityObject Object { get; set; } + + /// + /// The new location of the IIIF resource, after it was affected by a Move activity. + /// + [JsonProperty(Order = 7)] + public ActivityObject Target { get; set; } + + /// + /// The time at which the Activity was finished. + /// + [JsonProperty(Order = 10)] + [JsonConverter(typeof(XsdDateTimeConverter))] + public DateTime? EndTime { get; set; } + + /// + /// The time at which the Activity was started. + /// + [JsonProperty(Order = 11)] + [JsonConverter(typeof(XsdDateTimeConverter))] + public DateTime? StartTime { get; set; } + + /// + /// The organization, person, or software agent that carried out the Activity. + /// + [JsonProperty(Order = 21)] + public Actor? Actor { get; set; } + } + + public class ActivityObject : IService + { + [JsonProperty(Order = 2)] + public string? Id { get; set; } + + [JsonProperty(Order = 3)] + public string? Type { get; set; } + + [JsonProperty(Order = 4)] + public string? Canonical { get; set; } + + [JsonProperty(Order = 10)] + public List? SeeAlso { get; set; } + + [JsonProperty(Order = 11)] + public List? Provider { get; set; } + } + + public class Actor + { + [JsonProperty(Order = 2)] + public string? Id { get; set; } + + [JsonProperty(Order = 3)] + [EnumAsString] + public ActorType Type { get; set; } + } + + /// + /// Valid values for activity Type + /// + /// See: https://iiif.io/api/discovery/1.0/#type-activity + public enum ActivityType + { + /// + /// The initial creation of the resource. + /// + Create, + + /// + /// Any change to the resource. + /// + Update, + + /// + /// The deletion of the resource, or its de-publication from the web. + /// + Delete, + + /// + /// The re-publishing of the resource at a new URI, with the same content + /// + Move, + + /// + /// The addition of an object to a stream, outside of any of the above types of activity, such as a third + /// party aggregator adding resources from a newly discovered stream. + /// + Add, + + /// + /// The removal of an object from a stream, outside of any of the above types of activity, such as a third + /// party aggregator removing resources from a stream that are no longer considered to be in scope. + /// + Remove, + + /// + /// The beginning of an activity to refresh the stream with the state of all of the resources. + /// + Refresh + } + + public enum ActorType + { + Application, + Organization, + Person + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Discovery/V1/OrderedCollection.cs b/src/IIIF/IIIF/Discovery/V1/OrderedCollection.cs new file mode 100644 index 0000000..430f1ef --- /dev/null +++ b/src/IIIF/IIIF/Discovery/V1/OrderedCollection.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using IIIF.Presentation.V3.Content; +using Newtonsoft.Json; + +namespace IIIF.Discovery.V1 +{ + /// + /// The top-most resource for managing the lists of Activities + /// + /// See https://iiif.io/api/discovery/1.0/#orderedcollection + public class OrderedCollection : JsonLdBase, IService + { + [JsonProperty(Order = 2)] + public string? Id { get; set; } + + [JsonProperty(Order = 3)] + public string Type => nameof(OrderedCollection); + + /// + /// The total number of Activities in the entire Ordered Collection. + /// + [JsonProperty(Order = 5)] + public int? TotalItems { get; set; } + + /// + /// A string that identifies a license or rights statement that applies to the usage of the Ordered Collection. + /// + [JsonProperty(Order = 6)] + public string? Rights { get; set; } + + /// + /// Refers to one or more documents that semantically describe the set of resources that are being acted upon in + /// the Activities within the Ordered Collection, rather than any particular resource referenced from within the + /// collection + /// + [JsonProperty(Order = 10)] + public List? SeeAlso { get; set; } + + /// + /// This property is used to refer to a larger Ordered Collection, of which this Ordered Collection is part. + /// + [JsonProperty(Order = 11)] + public List? PartOf { get; set; } + + /// + /// A link to the first Ordered Collection Page for this Collection. + /// + [JsonProperty(Order = 20)] + public OrderedCollectionPage? First { get; set; } + + /// + /// A link to the last Ordered Collection Page for this Collection. + /// + [JsonProperty(Order = 21)] + public OrderedCollectionPage Last { get; set; } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Discovery/V1/OrderedCollectionPage.cs b/src/IIIF/IIIF/Discovery/V1/OrderedCollectionPage.cs new file mode 100644 index 0000000..1003c73 --- /dev/null +++ b/src/IIIF/IIIF/Discovery/V1/OrderedCollectionPage.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace IIIF.Discovery.V1 +{ + /// + /// A page of Activities + /// + /// See https://iiif.io/api/discovery/1.0/#ordered-collection-page + public class OrderedCollectionPage : JsonLdBase, IService + { + [JsonProperty(Order = 2)] + public string? Id { get; set; } + + [JsonProperty(Order = 3)] + public string Type => nameof(OrderedCollectionPage); + + /// + /// The position of the first item in this page’s orderedItems list, relative to the overall ordering across all + /// pages within the Collection. + /// + [JsonProperty(Order = 10)] + public int? StartIndex { get; set; } + + /// + /// The Ordered Collection of which this Page is a part. + /// + [JsonProperty(Order = 11)] + public OrderedCollection? PartOf { get; set; } + + /// + /// A reference to the previous page in the list of pages. + /// + [JsonProperty(Order = 20)] + public OrderedCollectionPage Prev { get; set; } + + /// + /// A reference to the next page in the list of pages. + /// + [JsonProperty(Order = 21)] + public OrderedCollectionPage? Next { get; set; } + + /// + /// The Activities that are listed as part of this page. + /// + [JsonProperty(Order = 22)] + public List OrderedItems { get; set; } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Serialisation/CamelCaseEnumAttribute.cs b/src/IIIF/IIIF/Serialisation/CamelCaseEnumAttribute.cs index 193557d..003a733 100644 --- a/src/IIIF/IIIF/Serialisation/CamelCaseEnumAttribute.cs +++ b/src/IIIF/IIIF/Serialisation/CamelCaseEnumAttribute.cs @@ -10,4 +10,13 @@ namespace IIIF.Serialisation public class CamelCaseEnumAttribute : Attribute { } + + /// + /// Any enum property decorated with this attribute will have a serialised value of it's string representation + /// in camelCase (e.g. InvalidRequest => invalidRequest) + /// + [AttributeUsage(AttributeTargets.Property)] + public class EnumAsStringAttribute : Attribute + { + } } \ No newline at end of file diff --git a/src/IIIF/IIIF/Serialisation/EnumStringValueConverter.cs b/src/IIIF/IIIF/Serialisation/EnumStringValueConverter.cs new file mode 100644 index 0000000..0be1659 --- /dev/null +++ b/src/IIIF/IIIF/Serialisation/EnumStringValueConverter.cs @@ -0,0 +1,22 @@ +using System; +using Newtonsoft.Json; + +namespace IIIF.Serialisation +{ + /// + /// Serialises enum as camelCase representation of enum value + /// + public class EnumStringValueConverter : WriteOnlyConverter + { + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is not Enum enumValue) + { + throw new ArgumentException( + $"EnumCamelCaseValueConverter expected enum but got {value.GetType().Name}", nameof(value)); + } + + writer.WriteValue(enumValue.ToString()); + } + } +} \ No newline at end of file diff --git a/src/IIIF/IIIF/Serialisation/ObjectIfSingleConverter.cs b/src/IIIF/IIIF/Serialisation/ObjectIfSingleConverter.cs index 5d5aa71..fd26a56 100644 --- a/src/IIIF/IIIF/Serialisation/ObjectIfSingleConverter.cs +++ b/src/IIIF/IIIF/Serialisation/ObjectIfSingleConverter.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using IIIF.Presentation.V2.Serialisation; using Newtonsoft.Json; namespace IIIF.Serialisation diff --git a/src/IIIF/IIIF/Serialisation/PrettyIIIFContractResolver.cs b/src/IIIF/IIIF/Serialisation/PrettyIIIFContractResolver.cs index 5c8b5ec..cd4eb86 100644 --- a/src/IIIF/IIIF/Serialisation/PrettyIIIFContractResolver.cs +++ b/src/IIIF/IIIF/Serialisation/PrettyIIIFContractResolver.cs @@ -15,7 +15,8 @@ public class PrettyIIIFContractResolver : CamelCasePropertyNamesContractResolver private static readonly ObjectIfSingleConverter ObjectIfSingleConverter = new(); private static readonly EnumCamelCaseValueConverter EnumCamelCaseValueConverter = new(); - + private static readonly EnumStringValueConverter EnumStringValueConverter = new(); + protected override JsonProperty CreateProperty( MemberInfo member, MemberSerialization memberSerialization) @@ -42,6 +43,11 @@ protected override JsonProperty CreateProperty( property.Converter = EnumCamelCaseValueConverter; } + if (member.GetCustomAttribute() != null) + { + property.Converter = EnumStringValueConverter; + } + // Don't serialise empty lists, unless they have the [RequiredOutput] attribute if (pType.IsGenericType && pType.GetGenericTypeDefinition() == typeof(List<>)) { diff --git a/src/IIIF/IIIF/Serialisation/XsdDateTimeConverter.cs b/src/IIIF/IIIF/Serialisation/XsdDateTimeConverter.cs new file mode 100644 index 0000000..bc2c783 --- /dev/null +++ b/src/IIIF/IIIF/Serialisation/XsdDateTimeConverter.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace IIIF.Serialisation +{ + /// + /// Outputs DateTime as a valid xsd:dateTime format, see https://www.w3.org/TR/xmlschema11-2/#dateTime + /// + public class XsdDateTimeConverter : WriteOnlyConverter + { + public override void WriteJson(JsonWriter writer, DateTime? value, JsonSerializer serializer) + { + if (value == null) return; + + var xsdDate = value.Value.ToString("yyyy-MM-ddTHH:mm:ssK"); + writer.WriteValue(xsdDate); + } + } +} \ No newline at end of file