Commit: a6b6571
Parent: 86b9fc8

Read images for recipes

Mårten Åsberg committed on 2026-07-24 at 10:52
src/JsonLdRecipeParser/Json/Alternatives.cs +137 -1
diff --git a/src/JsonLdRecipeParser/Json/Alternatives.cs b/src/JsonLdRecipeParser/Json/Alternatives.cs
index 6393fe5..902e6c5 100644
@@ -15,6 +15,11 @@ internal class Alternatives<T, U, V>
public object? Value { get; set; }
}
internal class Alternatives<T, U, V, W>
{
public object? Value { get; set; }
}
internal class AlternativesJsonConverter<A, T, U> : JsonConverter<A>
where A : Alternatives<T, U>, new()
{
@@ -145,6 +150,137 @@ internal class AlternativesJsonConverter<A, T, U, V> : JsonConverter<A>
}
}
internal class AlternativesJsonConverter<A, T, U, V, W> : JsonConverter<A>
where A : Alternatives<T, U, V, W>, new()
{
public override A? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new() { Value = JsonSerializer.Deserialize(ref reader, Classify(reader), options) };
protected virtual Type Classify(Utf8JsonReader reader)
{
var tKind = Kind.Of<T>();
var uKind = Kind.Of<U>();
var vKind = Kind.Of<V>();
var wKind = Kind.Of<W>();
if (tKind == uKind && tKind == vKind && tKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName}, {typeof(U).FullName}, {typeof(V).FullName}, and {typeof(W).FullName}"
);
}
if (tKind == uKind && tKind == vKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName}, {typeof(U).FullName}, and {typeof(V).FullName}"
);
}
if (tKind == uKind && tKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName}, {typeof(U).FullName}, and {typeof(W).FullName}"
);
}
if (tKind == vKind && tKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName}, {typeof(V).FullName}, and {typeof(W).FullName}"
);
}
if (uKind == vKind && uKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(U).FullName}, {typeof(V).FullName}, and {typeof(W).FullName}"
);
}
if (tKind == uKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName} and {typeof(U).FullName}"
);
}
if (tKind == vKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName} and {typeof(V).FullName}"
);
}
if (tKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName} and {typeof(W).FullName}"
);
}
if (uKind == vKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(U).FullName} and {typeof(V).FullName}"
);
}
if (uKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(U).FullName} and {typeof(W).FullName}"
);
}
if (vKind == wKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(V).FullName} and {typeof(W).FullName}"
);
}
return (reader.TokenType, tKind, uKind, vKind, wKind) switch
{
(JsonTokenType.StartObject, Kind.Object, _, _, _) => typeof(T),
(JsonTokenType.StartObject, _, Kind.Object, _, _) => typeof(U),
(JsonTokenType.StartObject, _, _, Kind.Object, _) => typeof(V),
(JsonTokenType.StartObject, _, _, _, Kind.Object) => typeof(W),
(JsonTokenType.StartArray, Kind.Array, _, _, _) => typeof(T),
(JsonTokenType.StartArray, _, Kind.Array, _, _) => typeof(U),
(JsonTokenType.StartArray, _, _, Kind.Array, _) => typeof(V),
(JsonTokenType.StartArray, _, _, _, Kind.Array) => typeof(W),
(JsonTokenType.String, Kind.String, _, _, _) => typeof(T),
(JsonTokenType.String, _, Kind.String, _, _) => typeof(U),
(JsonTokenType.String, _, _, Kind.String, _) => typeof(V),
(JsonTokenType.String, _, _, _, Kind.String) => typeof(W),
(JsonTokenType.Number, Kind.Number, _, _, _) => typeof(T),
(JsonTokenType.Number, _, Kind.Number, _, _) => typeof(U),
(JsonTokenType.Number, _, _, Kind.Number, _) => typeof(U),
(JsonTokenType.Number, _, _, _, Kind.Number) => typeof(W),
(JsonTokenType.True or JsonTokenType.False, Kind.Boolean, _, _, _) => typeof(T),
(JsonTokenType.True or JsonTokenType.False, _, Kind.Boolean, _, _) => typeof(U),
(JsonTokenType.True or JsonTokenType.False, _, _, Kind.Boolean, _) => typeof(U),
(JsonTokenType.True or JsonTokenType.False, _, _, _, Kind.Boolean) => typeof(W),
(var t, _, _, _, _) => throw new JsonException($"Unexpected token type {t}."),
};
}
public override void Write(Utf8JsonWriter writer, A value, JsonSerializerOptions options)
{
var valueType = value.Value?.GetType();
if (valueType == typeof(T))
{
JsonSerializer.Serialize(writer, value.Value, typeof(T), options);
}
else if (valueType == typeof(U))
{
JsonSerializer.Serialize(writer, value.Value, typeof(U), options);
}
else if (valueType == typeof(V))
{
JsonSerializer.Serialize(writer, value.Value, typeof(V), options);
}
else if (valueType == typeof(W))
{
JsonSerializer.Serialize(writer, value.Value, typeof(W), options);
}
else
{
throw new JsonException($"Unknown type {valueType?.FullName}.");
}
}
}
file enum Kind
{
Object,
@@ -161,7 +297,7 @@ file static class KindExtensions
public static Kind Of<T>()
{
var type = typeof(T);
if (type == typeof(string))
if (type == typeof(string) || type == typeof(Uri))
{
return Kind.String;
}
src/JsonLdRecipeParser/Json/CreativeWork.cs +1 -0
diff --git a/src/JsonLdRecipeParser/Json/CreativeWork.cs b/src/JsonLdRecipeParser/Json/CreativeWork.cs
index 7f3b799..3b54d61 100644
@@ -9,6 +9,7 @@ namespace JsonLdRecipeParser.Json;
)]
[JsonDerivedType(typeof(HowTo), "HowTo")]
[JsonDerivedType(typeof(Recipe), "Recipe")]
[JsonDerivedType(typeof(MediaObject), "MediaObject")]
internal class CreativeWork : Thing
{
[JsonPropertyName("author")]
src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs +1 -0
diff --git a/src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs b/src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs
index d03dc7a..4df9690 100644
@@ -5,6 +5,7 @@ namespace JsonLdRecipeParser.Json;
[JsonSerializable(typeof(JsonLd))]
[JsonSerializable(typeof(Thing))]
[JsonSerializable(typeof(Thing[]))]
[JsonSerializable(typeof(Images[]))]
[JsonSerializable(typeof(Person))]
[JsonSerializable(typeof(Organization))]
[JsonSerializable(typeof(Author))]
src/JsonLdRecipeParser/Json/MediaObject.cs +10 -0
diff --git a/src/JsonLdRecipeParser/Json/MediaObject.cs b/src/JsonLdRecipeParser/Json/MediaObject.cs
new file mode 100644
index 0000000..8f1a63a
@@ -0,0 +1,10 @@
using System;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class MediaObject : CreativeWork
{
[JsonPropertyName("contentUrl")]
public Uri? ContentUrl { get; set; }
}
src/JsonLdRecipeParser/Json/Thing.cs +8 -0
diff --git a/src/JsonLdRecipeParser/Json/Thing.cs b/src/JsonLdRecipeParser/Json/Thing.cs
index 82f4805..3587b52 100644
@@ -1,3 +1,4 @@
using System;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
@@ -20,6 +21,7 @@ namespace JsonLdRecipeParser.Json;
[JsonDerivedType(typeof(QuantitativeValue), "QuantitativeValue")]
[JsonDerivedType(typeof(Recipe), "Recipe")]
[JsonDerivedType(typeof(StructuredValue), "StructuredValue")]
[JsonDerivedType(typeof(MediaObject), "MediaObject")]
internal class Thing
{
[JsonPropertyName("name")]
@@ -27,4 +29,10 @@ internal class Thing
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("image")]
public Images? Image { get; set; }
}
[JsonConverter(typeof(AlternativesJsonConverter<Images, Uri, MediaObject, Images[]>))]
internal class Images : Alternatives<Uri, MediaObject, Images[]>;
src/JsonLdRecipeParser/JsonLdRecipeParser.csproj +1 -1
diff --git a/src/JsonLdRecipeParser/JsonLdRecipeParser.csproj b/src/JsonLdRecipeParser/JsonLdRecipeParser.csproj
index 23b2d51..82862bd 100644
@@ -5,7 +5,7 @@
</PropertyGroup>
<PropertyGroup>
<PackageId>JsonLdRecipeParser</PackageId>
<Version>0.0.3</Version>
<Version>0.0.4</Version>
<Authors>Mårten Åsberg</Authors>
<Company>MoreTec</Company>
<Description>Parses JSON-LD to a common recipe format.</Description>
src/JsonLdRecipeParser/Recipe.cs +21 -0
diff --git a/src/JsonLdRecipeParser/Recipe.cs b/src/JsonLdRecipeParser/Recipe.cs
index 10b9215..d66699b 100644
@@ -9,6 +9,7 @@ public class Recipe
{
public required string Name { get; set; }
public string? Description { get; set; }
public Uri[] Images { get; set; } = [];
public Author[] Authors { get; set; } = [];
public TimeSpan? TotalTime { get; set; }
public TimeSpan? PrepTime { get; set; }
@@ -36,6 +37,26 @@ public class Recipe
{
Name = recipe.Name ?? throw new Exception(),
Description = recipe.Description,
Images = recipe.Image?.Value switch
{
Uri url => [url],
MediaObject { ContentUrl: var url } => url is null ? [] : [url],
Images[] images =>
[
.. images
.Select(i =>
i.Value switch
{
Uri url => url,
MediaObject { ContentUrl: var url } => url,
_ => null,
}
)
.OfType<Uri>(),
],
null => [],
_ => throw new Exception(),
},
Authors = recipe.Author?.Value switch
{
Json.Author author => [Author.From(author)],
test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs +26 -1
diff --git a/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs b/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs
index 9eea7b1..dabd152 100644
@@ -38,6 +38,15 @@ public class DeserializeShould
{
Name = "Non-Alcoholic Piña Colada",
Description = "This non-alcoholic pina colada is everyone's favorite!",
Image = new Images
{
Value = new Images[]
{
new() { Value = new Uri("https://example.com/photos/1x1/photo.jpg") },
new() { Value = new Uri("https://example.com/photos/4x3/photo.jpg") },
new() { Value = new Uri("https://example.com/photos/16x9/photo.jpg") },
},
},
Author = new Authors
{
Value = new JsonLdRecipeParser.Json.Author { Value = new Person { Name = "Mary Stone" } },
@@ -62,11 +71,23 @@ public class DeserializeShould
{
Name = "Blend",
Text = "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
Image = new Images
{
Value = new Uri("https://example.com/photos/non-alcoholic-pina-colada/step1.jpg"),
},
},
},
new RecipeInstruction
{
Value = new HowToStep { Name = "Fill", Text = "Fill a glass with ice." },
Value = new HowToStep
{
Name = "Fill",
Text = "Fill a glass with ice.",
Image = new Images
{
Value = new Uri("https://example.com/photos/non-alcoholic-pina-colada/step2.jpg"),
},
},
},
new RecipeInstruction
{
@@ -74,6 +95,10 @@ public class DeserializeShould
{
Name = "Pour",
Text = "Pour the pineapple juice and coconut mixture over ice.",
Image = new Images
{
Value = new Uri("https://example.com/photos/non-alcoholic-pina-colada/step3.jpg"),
},
},
},
],
test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs +86 -0
diff --git a/test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs
index 5394870..b52a2b8 100644
@@ -27,6 +27,92 @@ public class FromShould
}
[TestMethod]
public void MapSingleImageUrl()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Image = new Images { Value = new Uri("https://example.org/image.png") },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Images.ShouldBeEquivalentTo(new Uri[] { new("https://example.org/image.png") });
}
[TestMethod]
public void MapMultipleImageUrls()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Image = new Images
{
Value = new Images[]
{
new() { Value = new Uri("https://example.org/image-1.png") },
new() { Value = new Uri("https://example.org/image-2.png") },
},
},
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Images.ShouldBeEquivalentTo(
new Uri[] { new("https://example.org/image-1.png"), new("https://example.org/image-2.png") }
);
}
[TestMethod]
public void MapSingleImageMediaObject()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Image = new Images { Value = new MediaObject { ContentUrl = new("https://example.org/image.png") } },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Images.ShouldBeEquivalentTo(new Uri[] { new("https://example.org/image.png") });
}
[TestMethod]
public void MapMultipleImageMediaObjects()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Image = new Images
{
Value = new Images[]
{
new() { Value = new MediaObject { ContentUrl = new("https://example.org/image-1.png") } },
new() { Value = new MediaObject { ContentUrl = new("https://example.org/image-2.png") } },
},
},
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Images.ShouldBeEquivalentTo(
new Uri[] { new("https://example.org/image-1.png"), new("https://example.org/image-2.png") }
);
}
[TestMethod]
public void MapASingleAuthor()
{
// Arrange
test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs +6 -0
diff --git a/test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs b/test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs
index 263fb05..70f8e56 100644
@@ -38,6 +38,12 @@ public class ParseShould
{
Name = "Non-Alcoholic Piña Colada",
Description = "This non-alcoholic pina colada is everyone's favorite!",
Images =
[
new("https://example.com/photos/1x1/photo.jpg"),
new("https://example.com/photos/4x3/photo.jpg"),
new("https://example.com/photos/16x9/photo.jpg"),
],
Authors = [new Author { Name = "Mary Stone" }],
PrepTime = TimeSpan.FromMinutes(1),
TotalTime = TimeSpan.FromMinutes(3),