Commit: 896bb52
Parent: c157a01

Deserialize Thing JSON

Mårten Åsberg committed on 2026-06-27 at 22:39
Directory.Build.props +1 -1
diff --git a/Directory.Build.props b/Directory.Build.props
index 066a318..06ec977 100644
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
Directory.Packages.props +1 -1
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ed20ced..320ffa3 100644
@@ -15,4 +15,4 @@
<PackageVersion Include="MSTest" Version="4.2.3" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
</ItemGroup>
</Project>
\ No newline at end of file
</Project>
src/CompilerServices/packages.lock.json +1 -1
diff --git a/src/CompilerServices/packages.lock.json b/src/CompilerServices/packages.lock.json
index 28ce62a..2d528eb 100644
@@ -1,7 +1,7 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"net11.0": {
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.3.0, )",
src/JsonLdRecipeParser/Amount.cs +1 -1
diff --git a/src/JsonLdRecipeParser/Amount.cs b/src/JsonLdRecipeParser/Amount.cs
index c1106ed..6d1a801 100644
@@ -37,7 +37,7 @@ public partial record Amount
return true;
}
private static bool TryParseValue(ref ReadOnlySpan<char> text, [NotNullWhen(true)] out AmountValue value)
private static bool TryParseValue(ref ReadOnlySpan<char> text, [NotNullWhen(true)] out AmountValue? value)
{
var match = Pattern.Match(text.ToString());
if (!match.Success)
src/JsonLdRecipeParser/Json/Alternatives.cs +186 -0
diff --git a/src/JsonLdRecipeParser/Json/Alternatives.cs b/src/JsonLdRecipeParser/Json/Alternatives.cs
new file mode 100644
index 0000000..6393fe5
@@ -0,0 +1,186 @@
using System;
using System.Collections;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class Alternatives<T, U>
{
public object? Value { get; set; }
}
internal class Alternatives<T, U, V>
{
public object? Value { get; set; }
}
internal class AlternativesJsonConverter<A, T, U> : JsonConverter<A>
where A : Alternatives<T, U>, 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>();
if (tKind == uKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(T).FullName} and {typeof(U).FullName}"
);
}
return (reader.TokenType, tKind, uKind) switch
{
(JsonTokenType.StartObject, Kind.Object, _) => typeof(T),
(JsonTokenType.StartObject, _, Kind.Object) => typeof(U),
(JsonTokenType.StartArray, Kind.Array, _) => typeof(T),
(JsonTokenType.StartArray, _, Kind.Array) => typeof(U),
(JsonTokenType.String, Kind.String, _) => typeof(T),
(JsonTokenType.String, _, Kind.String) => typeof(U),
(JsonTokenType.Number, Kind.Number, _) => typeof(T),
(JsonTokenType.Number, _, Kind.Number) => typeof(U),
(JsonTokenType.True or JsonTokenType.False, Kind.Boolean, _) => typeof(T),
(JsonTokenType.True or JsonTokenType.False, _, Kind.Boolean) => typeof(U),
(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
{
throw new JsonException($"Unknown type {valueType?.FullName}.");
}
}
}
internal class AlternativesJsonConverter<A, T, U, V> : JsonConverter<A>
where A : Alternatives<T, U, V>, 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>();
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)
{
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 (uKind == vKind)
{
throw new JsonException(
$"Cannot implicitly differentiate between {typeof(U).FullName} and {typeof(V).FullName}"
);
}
return (reader.TokenType, tKind, uKind, vKind) switch
{
(JsonTokenType.StartObject, Kind.Object, _, _) => typeof(T),
(JsonTokenType.StartObject, _, Kind.Object, _) => typeof(U),
(JsonTokenType.StartObject, _, _, Kind.Object) => typeof(V),
(JsonTokenType.StartArray, Kind.Array, _, _) => typeof(T),
(JsonTokenType.StartArray, _, Kind.Array, _) => typeof(U),
(JsonTokenType.StartArray, _, _, Kind.Array) => typeof(V),
(JsonTokenType.String, Kind.String, _, _) => typeof(T),
(JsonTokenType.String, _, Kind.String, _) => typeof(U),
(JsonTokenType.String, _, _, Kind.String) => typeof(V),
(JsonTokenType.Number, Kind.Number, _, _) => typeof(T),
(JsonTokenType.Number, _, Kind.Number, _) => typeof(U),
(JsonTokenType.Number, _, _, Kind.Number) => typeof(U),
(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),
(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
{
throw new JsonException($"Unknown type {valueType?.FullName}.");
}
}
}
file enum Kind
{
Object,
Array,
String,
Number,
Boolean,
}
file static class KindExtensions
{
extension(Kind)
{
public static Kind Of<T>()
{
var type = typeof(T);
if (type == typeof(string))
{
return Kind.String;
}
else if (type == typeof(double))
{
return Kind.Number;
}
else if (type == typeof(bool))
{
return Kind.Boolean;
}
else if (type.IsAssignableTo(typeof(IEnumerable)))
{
return Kind.Array;
}
else
{
return Kind.Object;
}
}
}
}
src/JsonLdRecipeParser/Json/Author.cs +38 -0
diff --git a/src/JsonLdRecipeParser/Json/Author.cs b/src/JsonLdRecipeParser/Json/Author.cs
new file mode 100644
index 0000000..b818e21
@@ -0,0 +1,38 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(AuthorJsonConverter))]
internal class Author : Alternatives<Person, Organization>;
internal class AuthorJsonConverter : AlternativesJsonConverter<Author, Person, Organization>
{
protected override Type Classify(Utf8JsonReader reader)
{
if (reader.TokenType is not JsonTokenType.StartObject)
{
throw new JsonException("Cannot classify.");
}
while (reader.Read() && reader.TokenType is JsonTokenType.PropertyName)
{
if (reader.ValueTextEquals("@type"))
{
reader.Read();
return reader.GetString() switch
{
"Person" => typeof(Person),
"Organization" => typeof(Organization),
_ => throw new JsonException("Cannot classify."),
};
}
reader.Read();
reader.Skip();
}
throw new JsonException("Cannot classify.");
}
}
src/JsonLdRecipeParser/Json/CreativeWork.cs +19 -0
diff --git a/src/JsonLdRecipeParser/Json/CreativeWork.cs b/src/JsonLdRecipeParser/Json/CreativeWork.cs
new file mode 100644
index 0000000..7f3b799
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(HowTo), "HowTo")]
[JsonDerivedType(typeof(Recipe), "Recipe")]
internal class CreativeWork : Thing
{
[JsonPropertyName("author")]
public Authors? Author { get; set; }
}
[JsonConverter(typeof(AlternativesJsonConverter<Authors, Author, Author[]>))]
internal class Authors : Alternatives<Author, Author[]>;
src/JsonLdRecipeParser/Json/HowTo.cs +19 -0
diff --git a/src/JsonLdRecipeParser/Json/HowTo.cs b/src/JsonLdRecipeParser/Json/HowTo.cs
new file mode 100644
index 0000000..49133a8
@@ -0,0 +1,19 @@
using System;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(Recipe), "Recipe")]
internal class HowTo : CreativeWork
{
[JsonPropertyName("prepTime")]
public TimeSpan? PrepTime { get; set; }
[JsonPropertyName("totalTime")]
public TimeSpan? TotalTime { get; set; }
}
src/JsonLdRecipeParser/Json/HowToSection.cs +9 -0
diff --git a/src/JsonLdRecipeParser/Json/HowToSection.cs b/src/JsonLdRecipeParser/Json/HowToSection.cs
new file mode 100644
index 0000000..c5e48e6
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class HowToSection : ItemList
{
[JsonPropertyName("text")]
public string? Text { get; set; }
}
src/JsonLdRecipeParser/Json/HowToStep.cs +9 -0
diff --git a/src/JsonLdRecipeParser/Json/HowToStep.cs b/src/JsonLdRecipeParser/Json/HowToStep.cs
new file mode 100644
index 0000000..58fc26b
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class HowToStep : ItemList
{
[JsonPropertyName("text")]
public string? Text { get; set; }
}
src/JsonLdRecipeParser/Json/ISODurationConverter.cs +15 -0
diff --git a/src/JsonLdRecipeParser/Json/ISODurationConverter.cs b/src/JsonLdRecipeParser/Json/ISODurationConverter.cs
new file mode 100644
index 0000000..fb36d2f
@@ -0,0 +1,15 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;
namespace JsonLdRecipeParser.Json;
internal class ISODurationConverter : JsonConverter<TimeSpan>
{
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
XmlConvert.ToTimeSpan(reader.GetString() ?? throw new JsonException("Expected ISO Duration in string."));
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) =>
writer.WriteStringValue(XmlConvert.ToString(value));
}
src/JsonLdRecipeParser/Json/Intangible.cs +17 -0
diff --git a/src/JsonLdRecipeParser/Json/Intangible.cs b/src/JsonLdRecipeParser/Json/Intangible.cs
new file mode 100644
index 0000000..e02b7ee
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(HowToSection), "HowToSection")]
[JsonDerivedType(typeof(HowToStep), "HowToStep")]
[JsonDerivedType(typeof(ItemList), "ItemList")]
[JsonDerivedType(typeof(NutritionInformation), "NutritionInformation")]
[JsonDerivedType(typeof(PropertyValue), "PropertyValue")]
[JsonDerivedType(typeof(QuantitativeValue), "QuantitativeValue")]
[JsonDerivedType(typeof(StructuredValue), "StructuredValue")]
internal class Intangible : Thing;
src/JsonLdRecipeParser/Json/ItemList.cs +19 -0
diff --git a/src/JsonLdRecipeParser/Json/ItemList.cs b/src/JsonLdRecipeParser/Json/ItemList.cs
new file mode 100644
index 0000000..299c3be
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(HowToStep), "HowToStep")]
[JsonDerivedType(typeof(HowToSection), "HowToSection")]
internal class ItemList : Intangible
{
[JsonPropertyName("itemListElement")]
public ItemListElement[]? ItemListElement { get; set; }
}
[JsonConverter(typeof(AlternativesJsonConverter<ItemListElement, ItemList, string>))]
internal class ItemListElement : Alternatives<ItemList, string>;
src/JsonLdRecipeParser/Json/JsonLd.cs +6 -0
diff --git a/src/JsonLdRecipeParser/Json/JsonLd.cs b/src/JsonLdRecipeParser/Json/JsonLd.cs
new file mode 100644
index 0000000..5ff4e37
@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(AlternativesJsonConverter<JsonLd, Thing, Thing[]>))]
internal class JsonLd : Alternatives<Thing, Thing[]>;
src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs +26 -0
diff --git a/src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs b/src/JsonLdRecipeParser/Json/JsonLdSerializerContext.cs
new file mode 100644
index 0000000..d03dc7a
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonSerializable(typeof(JsonLd))]
[JsonSerializable(typeof(Thing))]
[JsonSerializable(typeof(Thing[]))]
[JsonSerializable(typeof(Person))]
[JsonSerializable(typeof(Organization))]
[JsonSerializable(typeof(Author))]
[JsonSerializable(typeof(Author[]))]
[JsonSerializable(typeof(ItemList))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(QuantitativeValue))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(PropertyValue))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(StructuredValue))]
[JsonSourceGenerationOptions(
AllowOutOfOrderMetadataProperties = true,
AllowTrailingCommas = true,
AllowDuplicateProperties = true,
Converters = [typeof(ISODurationConverter)],
NumberHandling = JsonNumberHandling.AllowReadingFromString
)]
internal partial class JsonLdSerializerContext : JsonSerializerContext;
src/JsonLdRecipeParser/Json/NutritionInformation.cs +42 -0
diff --git a/src/JsonLdRecipeParser/Json/NutritionInformation.cs b/src/JsonLdRecipeParser/Json/NutritionInformation.cs
new file mode 100644
index 0000000..27e6c72
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class NutritionInformation : StructuredValue
{
[JsonPropertyName("calories")]
public Quantity? Calories { get; set; }
[JsonPropertyName("carbohydrateContent")]
public Quantity? CarbohydrateContent { get; set; }
[JsonPropertyName("cholesterolContent")]
public Quantity? CholesterolContent { get; set; }
[JsonPropertyName("fatContent")]
public Quantity? FatContent { get; set; }
[JsonPropertyName("fiberContent")]
public Quantity? FiberContent { get; set; }
[JsonPropertyName("proteinContent")]
public Quantity? ProteinContent { get; set; }
[JsonPropertyName("saturatedFatContent")]
public Quantity? SaturatedFatContent { get; set; }
[JsonPropertyName("servingSize")]
public Quantity? ServingSize { get; set; }
[JsonPropertyName("sodiumContent")]
public Quantity? SodiumContent { get; set; }
[JsonPropertyName("sugarContent")]
public Quantity? SugarContent { get; set; }
[JsonPropertyName("transFatContent")]
public Quantity? TransFatContent { get; set; }
[JsonPropertyName("unsaturatedFatContent")]
public Quantity? UnsaturatedFatContent { get; set; }
}
src/JsonLdRecipeParser/Json/Organization.cs +3 -0
diff --git a/src/JsonLdRecipeParser/Json/Organization.cs b/src/JsonLdRecipeParser/Json/Organization.cs
new file mode 100644
index 0000000..fec4ace
@@ -0,0 +1,3 @@
namespace JsonLdRecipeParser.Json;
internal class Organization : Thing;
src/JsonLdRecipeParser/Json/Person.cs +3 -0
diff --git a/src/JsonLdRecipeParser/Json/Person.cs b/src/JsonLdRecipeParser/Json/Person.cs
new file mode 100644
index 0000000..b8a4777
@@ -0,0 +1,3 @@
namespace JsonLdRecipeParser.Json;
internal class Person : Thing;
src/JsonLdRecipeParser/Json/PropertyValue.cs +15 -0
diff --git a/src/JsonLdRecipeParser/Json/PropertyValue.cs b/src/JsonLdRecipeParser/Json/PropertyValue.cs
new file mode 100644
index 0000000..d3f29c1
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class PropertyValue : StructuredValue
{
[JsonPropertyName("unitCode")]
public string? UnitCode { get; set; }
[JsonPropertyName("unitText")]
public string? UnitText { get; set; }
[JsonPropertyName("value")]
public ValueProperty? Value { get; set; }
}
src/JsonLdRecipeParser/Json/Quantity.cs +18 -0
diff --git a/src/JsonLdRecipeParser/Json/Quantity.cs b/src/JsonLdRecipeParser/Json/Quantity.cs
new file mode 100644
index 0000000..cf30026
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(AlternativesJsonConverter<Quantity, string, QuantitativeValue>))]
internal class Quantity : Alternatives<string, QuantitativeValue>;
internal class QuantitativeValue : StructuredValue
{
[JsonPropertyName("unitCode")]
public string? UnitCode { get; set; }
[JsonPropertyName("unitText")]
public string? UnitText { get; set; }
[JsonPropertyName("value")]
public ValueProperty? Value { get; set; }
}
src/JsonLdRecipeParser/Json/Recipe.cs +34 -0
diff --git a/src/JsonLdRecipeParser/Json/Recipe.cs b/src/JsonLdRecipeParser/Json/Recipe.cs
new file mode 100644
index 0000000..c217bda
@@ -0,0 +1,34 @@
using System;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
internal class Recipe : HowTo
{
[JsonPropertyName("cookTime")]
public TimeSpan? CookTime { get; set; }
[JsonPropertyName("nutrition")]
public NutritionInformation? Nutrition { get; set; }
[JsonPropertyName("recipeCategory")]
public RecipeCategories? RecipeCategory { get; set; }
[JsonPropertyName("recipeCuisine")]
public RecipeCuisines? RecipeCuisine { get; set; }
[JsonPropertyName("recipeIngredient")]
public RecipeIngredient[]? RecipeIngredient { get; set; }
[JsonPropertyName("recipeInstructions")]
public RecipeInstruction[]? RecipeInstructions { get; set; }
[JsonPropertyName("recipeYield")]
public Quantity? RecipeYield { get; set; }
}
[JsonConverter(typeof(AlternativesJsonConverter<RecipeCategories, string, string[]>))]
internal class RecipeCategories : Alternatives<string, string[]>;
[JsonConverter(typeof(AlternativesJsonConverter<RecipeCuisines, string, string[]>))]
internal class RecipeCuisines : Alternatives<string, string[]>;
src/JsonLdRecipeParser/Json/RecipeIngredient.cs +44 -0
diff --git a/src/JsonLdRecipeParser/Json/RecipeIngredient.cs b/src/JsonLdRecipeParser/Json/RecipeIngredient.cs
new file mode 100644
index 0000000..5d3ab92
@@ -0,0 +1,44 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(RecipeIngredientJsonConverter))]
internal class RecipeIngredient : Alternatives<ItemList, PropertyValue, string>;
internal class RecipeIngredientJsonConverter
: AlternativesJsonConverter<RecipeIngredient, ItemList, PropertyValue, string>
{
protected override Type Classify(Utf8JsonReader reader)
{
if (reader.TokenType is JsonTokenType.String)
{
return typeof(string);
}
if (reader.TokenType is not JsonTokenType.StartObject)
{
throw new JsonException("Cannot classify.");
}
while (reader.Read() && reader.TokenType is JsonTokenType.PropertyName)
{
if (reader.ValueTextEquals("@type"))
{
reader.Read();
return reader.GetString() switch
{
"ItemList" => typeof(ItemList),
"PropertyValue" => typeof(PropertyValue),
_ => throw new JsonException("Cannot classify."),
};
}
reader.Read();
reader.Skip();
}
throw new JsonException("Cannot classify.");
}
}
src/JsonLdRecipeParser/Json/RecipeInstruction.cs +6 -0
diff --git a/src/JsonLdRecipeParser/Json/RecipeInstruction.cs b/src/JsonLdRecipeParser/Json/RecipeInstruction.cs
new file mode 100644
index 0000000..2cecb8e
@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(AlternativesJsonConverter<RecipeInstruction, ItemList, string>))]
internal class RecipeInstruction : Alternatives<ItemList, string>;
src/JsonLdRecipeParser/Json/StructuredValue.cs +13 -0
diff --git a/src/JsonLdRecipeParser/Json/StructuredValue.cs b/src/JsonLdRecipeParser/Json/StructuredValue.cs
new file mode 100644
index 0000000..add105f
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(NutritionInformation), "NutritionInformation")]
[JsonDerivedType(typeof(PropertyValue), "PropertyValue")]
[JsonDerivedType(typeof(QuantitativeValue), "QuantitativeValue")]
internal class StructuredValue : Intangible;
src/JsonLdRecipeParser/Json/Thing.cs +30 -0
diff --git a/src/JsonLdRecipeParser/Json/Thing.cs b/src/JsonLdRecipeParser/Json/Thing.cs
new file mode 100644
index 0000000..82f4805
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "@type",
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType(typeof(CreativeWork), "CreativeWork")]
[JsonDerivedType(typeof(HowTo), "HowTo")]
[JsonDerivedType(typeof(HowToSection), "HowToSection")]
[JsonDerivedType(typeof(HowToStep), "HowToStep")]
[JsonDerivedType(typeof(Intangible), "Intangible")]
[JsonDerivedType(typeof(ItemList), "ItemList")]
[JsonDerivedType(typeof(NutritionInformation), "NutritionInformation")]
[JsonDerivedType(typeof(Organization), "Organization")]
[JsonDerivedType(typeof(Person), "Person")]
[JsonDerivedType(typeof(PropertyValue), "PropertyValue")]
[JsonDerivedType(typeof(QuantitativeValue), "QuantitativeValue")]
[JsonDerivedType(typeof(Recipe), "Recipe")]
[JsonDerivedType(typeof(StructuredValue), "StructuredValue")]
internal class Thing
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
}
src/JsonLdRecipeParser/Json/ValueProperty.cs +6 -0
diff --git a/src/JsonLdRecipeParser/Json/ValueProperty.cs b/src/JsonLdRecipeParser/Json/ValueProperty.cs
new file mode 100644
index 0000000..d22ca89
@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace JsonLdRecipeParser.Json;
[JsonConverter(typeof(AlternativesJsonConverter<ValueProperty, double, StructuredValue, string>))]
internal class ValueProperty : Alternatives<double, StructuredValue, string>;
src/JsonLdRecipeParser/JsonLdRecipeParser.csproj +3 -0
diff --git a/src/JsonLdRecipeParser/JsonLdRecipeParser.csproj b/src/JsonLdRecipeParser/JsonLdRecipeParser.csproj
index c9e755b..f1f8dcb 100644
@@ -4,6 +4,9 @@
<AssemblyName>JsonLdRecipeParser</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="JsonLdRecipeParser.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CompilerServices\CompilerServices.csproj" Private="True" />
</ItemGroup>
</Project>
src/JsonLdRecipeParser/Nutrients.cs +7 -1
diff --git a/src/JsonLdRecipeParser/Nutrients.cs b/src/JsonLdRecipeParser/Nutrients.cs
index 09a574b..3a93c9e 100644
@@ -4,8 +4,14 @@ public class Nutrients
{
public Amount? ServingSize { get; set; }
public Amount? Calories { get; set; }
public Amount? Carbohydrates { get; set; }
public Amount? Carbohydrate { get; set; }
public Amount? Cholesterol { get; set; }
public Amount? Fat { get; set; }
public Amount? Fiber { get; set; }
public Amount? Protein { get; set; }
public Amount? SaturatedFat { get; set; }
public Amount? Sodium { get; set; }
public Amount? Sugar { get; set; }
public Amount? TransFat { get; set; }
public Amount? UnsaturatedFat { get; set; }
}
src/JsonLdRecipeParser/packages.lock.json +1 -1
diff --git a/src/JsonLdRecipeParser/packages.lock.json b/src/JsonLdRecipeParser/packages.lock.json
index 243053e..397414e 100644
@@ -1,7 +1,7 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"net11.0": {
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.3.0, )",
test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs +185 -0
diff --git a/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs b/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs
new file mode 100644
index 0000000..3197c8f
@@ -0,0 +1,185 @@
using System;
using System.Text.Json;
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.Json.JsonLdSerializerContextTests;
[TestClass]
public class DeserializeShould
{
const string googleExample = """
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Non-Alcoholic Piña Colada",
"image": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"author": {
"@type": "Person",
"name": "Mary Stone"
},
"datePublished": "2018-03-10",
"description": "This non-alcoholic pina colada is everyone's favorite!",
"recipeCuisine": "American",
"prepTime": "PT1M",
"cookTime": "PT2M",
"totalTime": "PT3M",
"keywords": "non-alcoholic",
"recipeYield": "4 servings",
"recipeCategory": "Drink",
"nutrition": {
"@type": "NutritionInformation",
"calories": "120 calories"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": "18"
},
"recipeIngredient": [
"400ml of pineapple juice",
"100ml cream of coconut",
"ice"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Blend",
"text": "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
"url": "https://example.com/non-alcoholic-pina-colada#step1",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step1.jpg"
},
{
"@type": "HowToStep",
"name": "Fill",
"text": "Fill a glass with ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step2",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step2.jpg"
},
{
"@type": "HowToStep",
"name": "Pour",
"text": "Pour the pineapple juice and coconut mixture over ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step3",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step3.jpg"
}
],
"video": {
"@type": "VideoObject",
"name": "How to Make a Non-Alcoholic Piña Colada",
"description": "This is how you make a non-alcoholic piña colada.",
"thumbnailUrl": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"contentUrl": "https://www.example.com/video123.mp4",
"embedUrl": "https://www.example.com/videoplayer?video=123",
"uploadDate": "2018-02-05T08:00:00+08:00",
"duration": "PT1M33S",
"interactionStatistic": {
"@type": "InteractionCounter",
"interactionType": {
"@type": "WatchAction"
},
"userInteractionCount": 2347
},
"expires": "2019-02-05T08:00:00+08:00"
}
}
""";
[TestMethod]
public void DeserializeAnEmptyArray()
{
// Arrange
var json = "[]";
// Act
var result = JsonSerializer.Deserialize(json, JsonLdSerializerContext.Default.JsonLd);
// Assert
result.ShouldNotBeNull();
var things = result.Value.ShouldBeOfType<Thing[]>();
things.ShouldBeEmpty();
}
[TestMethod]
public void DeserializeJsonLdFromGoogleExample()
{
// Act
var result = JsonSerializer.Deserialize(googleExample, JsonLdSerializerContext.Default.JsonLd);
// Assert
result.ShouldNotBeNull();
var recipe = result.Value.ShouldBeOfType<JsonLdRecipeParser.Json.Recipe>();
recipe.ShouldBeEquivalentTo(
new JsonLdRecipeParser.Json.Recipe
{
Name = "Non-Alcoholic Piña Colada",
Description = "This non-alcoholic pina colada is everyone's favorite!",
Author = new Authors
{
Value = new JsonLdRecipeParser.Json.Author { Value = new Person { Name = "Mary Stone" } },
},
PrepTime = TimeSpan.FromMinutes(1),
TotalTime = TimeSpan.FromMinutes(3),
CookTime = TimeSpan.FromMinutes(2),
Nutrition = new NutritionInformation { Calories = new Quantity { Value = "120 calories" } },
RecipeCategory = new RecipeCategories { Value = "Drink" },
RecipeCuisine = new RecipeCuisines { Value = "American" },
RecipeIngredient =
[
new RecipeIngredient { Value = "400ml of pineapple juice" },
new RecipeIngredient { Value = "100ml cream of coconut" },
new RecipeIngredient { Value = "ice" },
],
RecipeInstructions =
[
new RecipeInstruction
{
Value = new HowToStep
{
Name = "Blend",
Text = "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
},
},
new RecipeInstruction
{
Value = new HowToStep { Name = "Fill", Text = "Fill a glass with ice." },
},
new RecipeInstruction
{
Value = new HowToStep
{
Name = "Pour",
Text = "Pour the pineapple juice and coconut mixture over ice.",
},
},
],
RecipeYield = new Quantity { Value = "4 servings" },
}
);
}
[TestMethod]
public void DeserializeJsonLdFromGoogleExampleInArray()
{
// Arrange
const string json = $"[{googleExample}]";
// Act
var result = JsonSerializer.Deserialize(json, JsonLdSerializerContext.Default.JsonLd);
// Assert
result.ShouldNotBeNull();
var things = result.Value.ShouldBeOfType<Thing[]>();
things.Length.ShouldBe(1);
things[0].ShouldBeOfType<JsonLdRecipeParser.Json.Recipe>();
}
}
test/JsonLdRecipeParser.Tests/packages.lock.json +1 -1
diff --git a/test/JsonLdRecipeParser.Tests/packages.lock.json b/test/JsonLdRecipeParser.Tests/packages.lock.json
index b257be5..c522fdc 100644
@@ -1,7 +1,7 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"net11.0": {
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.3.0, )",