Skip to content

Commit 3664cf7

Browse files
authored
Schema.org Release v7 (#136)
* PronounceableText support * Schema v6 entity updates * Update to schema.org release v7 * Added Values7 type * Added tests for Values7 * Only support DateTimeOffset conversion if it looks like it has an offset ISO 8601 defines the offset as numbers after a "+" as the offset or after a "Z" meaning UTC0. Without this change, the timezone offset of the current machine is used which is (in many cases) undesired. * Adding better support for time offsets in dates * Fixed bug in time offset handling via substrings
1 parent b9709dd commit 3664cf7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+3886
-1267
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
namespace Schema.NET
2+
{
3+
using System;
4+
5+
/// <summary>
6+
/// Helper for parsing strings into <see cref="DateTime"/> or <see cref="DateTimeOffset"/>
7+
/// </summary>
8+
internal static class DateTimeHelper
9+
{
10+
private const string MSDateStringStart = "/Date(";
11+
private const string MSDateStringEnd = ")/";
12+
private const string NegativeOffset = "-";
13+
private static readonly DateTime EpochDate = new DateTime(1970, 1, 1);
14+
private static readonly char[] OffsetChars = new[] { '+', '-' };
15+
16+
/// <summary>
17+
/// Whether a given ISO 8601 date string has an offset defined (eg. "+", "-", "Z")
18+
/// </summary>
19+
/// <param name="input">The input string</param>
20+
/// <returns>True if the input string has an offset defined</returns>
21+
public static bool ContainsTimeOffset(string input)
22+
{
23+
if (input.IndexOf("+", StringComparison.Ordinal) != -1 || input.IndexOf("Z", StringComparison.Ordinal) != -1)
24+
{
25+
return true;
26+
}
27+
28+
var timeSeparatorIndex = input.IndexOf("T", StringComparison.Ordinal);
29+
if (timeSeparatorIndex != -1)
30+
{
31+
return input.IndexOf(NegativeOffset, timeSeparatorIndex, StringComparison.Ordinal) != -1;
32+
}
33+
34+
return false;
35+
}
36+
37+
/// <summary>
38+
/// Parse MS DateTime as Date eg. "/Date(946730040000)/"
39+
/// </summary>
40+
/// <param name="input">The input string</param>
41+
/// <param name="result">The result date and time</param>
42+
/// <returns>True if the input string was able to be parsed into a <see cref="DateTime"/></returns>
43+
public static bool TryParseMSDateTime(string input, out DateTime result)
44+
{
45+
if (input.StartsWith(MSDateStringStart, StringComparison.Ordinal) && input.EndsWith(MSDateStringEnd, StringComparison.Ordinal))
46+
{
47+
var dateTimeStartIndex = MSDateStringStart.Length;
48+
var dateTimeLength = input.IndexOf(MSDateStringEnd, StringComparison.Ordinal) - dateTimeStartIndex;
49+
50+
#if NETCOREAPP3_1
51+
var timeValue = input.AsSpan().Slice(dateTimeStartIndex, dateTimeLength);
52+
#else
53+
var timeValue = input.Substring(dateTimeStartIndex, dateTimeLength);
54+
#endif
55+
56+
if (double.TryParse(timeValue, out var milliseconds))
57+
{
58+
result = EpochDate.AddMilliseconds(milliseconds);
59+
return true;
60+
}
61+
}
62+
63+
result = DateTime.MinValue;
64+
return false;
65+
}
66+
67+
/// <summary>
68+
/// Parse MS DateTime as <see cref="DateTimeOffset"/> eg. "/Date(946730040000-0100)/"
69+
/// </summary>
70+
/// <param name="input">The input string</param>
71+
/// <param name="result">The result date and time with offset</param>
72+
/// <returns>True if the input string was able to be parsed into a <see cref="DateTimeOffset"/></returns>
73+
public static bool TryParseMSDateTimeOffset(string input, out DateTimeOffset result)
74+
{
75+
if (input.StartsWith(MSDateStringStart, StringComparison.Ordinal) && input.EndsWith(MSDateStringEnd, StringComparison.Ordinal))
76+
{
77+
var dateTimeStartIndex = MSDateStringStart.Length;
78+
var offsetIndex = input.IndexOfAny(OffsetChars);
79+
var dateTimeLength = offsetIndex - dateTimeStartIndex;
80+
var offsetLength = input.IndexOf(MSDateStringEnd, offsetIndex, StringComparison.Ordinal) - offsetIndex;
81+
82+
#if NETCOREAPP3_1
83+
var timeValue = input.AsSpan().Slice(dateTimeStartIndex, dateTimeLength);
84+
var offsetType = input.AsSpan().Slice(offsetIndex, 1);
85+
var offsetValue = input.AsSpan().Slice(offsetIndex + 1, offsetLength - 1);
86+
#else
87+
var timeValue = input.Substring(dateTimeStartIndex, dateTimeLength);
88+
var offsetType = input.Substring(offsetIndex, 1);
89+
var offsetValue = input.Substring(offsetIndex + 1, offsetLength - 1);
90+
#endif
91+
92+
if (double.TryParse(timeValue, out var milliseconds))
93+
{
94+
if (int.TryParse(offsetValue, out var offset))
95+
{
96+
var dateTime = EpochDate.AddMilliseconds(milliseconds);
97+
var hours = offset / 100;
98+
var minutes = offset - (hours * 100);
99+
var offsetTimeSpan = new TimeSpan(hours, minutes, 0);
100+
101+
if (offsetType[0] == NegativeOffset[0])
102+
{
103+
offsetTimeSpan = -offsetTimeSpan;
104+
}
105+
106+
result = new DateTimeOffset(dateTime, offsetTimeSpan);
107+
return true;
108+
}
109+
}
110+
}
111+
112+
result = DateTimeOffset.MinValue;
113+
return false;
114+
}
115+
}
116+
}

Source/Schema.NET/Schema.NET.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup Label="Build">
4-
<TargetFrameworks>netstandard1.1;netstandard2.0;net461;net472</TargetFrameworks>
4+
<TargetFrameworks>netstandard1.1;netstandard2.0;net461;net472;netcoreapp3.1</TargetFrameworks>
55
<GenerateDocumentationFile>true</GenerateDocumentationFile>
66
<LangVersion>latest</LangVersion>
77
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
namespace Schema.NET
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Text;
6+
using Newtonsoft.Json;
7+
using Newtonsoft.Json.Converters;
8+
9+
/// <summary>
10+
/// Schema JSON Serializer
11+
/// </summary>
12+
public static class SchemaSerializer
13+
{
14+
private const string ContextPropertyJson = "\"@context\":\"https://schema.org\",";
15+
16+
/// <summary>
17+
/// Default serializer settings used when deserializing
18+
/// </summary>
19+
private static readonly JsonSerializerSettings DeserializeSettings = new JsonSerializerSettings
20+
{
21+
DateParseHandling = DateParseHandling.None,
22+
};
23+
24+
/// <summary>
25+
/// Default serializer settings used when HTML escaping is not required.
26+
/// </summary>
27+
private static readonly JsonSerializerSettings DefaultSerializationSettings = new JsonSerializerSettings()
28+
{
29+
Converters = new List<JsonConverter>()
30+
{
31+
new StringEnumConverter(),
32+
},
33+
DefaultValueHandling = DefaultValueHandling.Ignore,
34+
NullValueHandling = NullValueHandling.Ignore,
35+
};
36+
37+
/// <summary>
38+
/// Serializer settings used when trying to avoid XSS vulnerabilities where user-supplied data is used
39+
/// and the output of the serialization is embedded into a web page raw.
40+
/// </summary>
41+
private static readonly JsonSerializerSettings HtmlEscapedSerializationSettings = new JsonSerializerSettings()
42+
{
43+
Converters = new List<JsonConverter>()
44+
{
45+
new StringEnumConverter(),
46+
},
47+
DefaultValueHandling = DefaultValueHandling.Ignore,
48+
NullValueHandling = NullValueHandling.Ignore,
49+
StringEscapeHandling = StringEscapeHandling.EscapeHtml,
50+
};
51+
52+
/// <summary>
53+
/// Deserializes the JSON to the specified type.
54+
/// </summary>
55+
/// <typeparam name="T">Deserialization target type</typeparam>
56+
/// <param name="value">JSON to deserialize</param>
57+
/// <returns>An instance of <typeparamref name="T"/> deserialized from JSON</returns>
58+
public static T DeserializeObject<T>(string value)
59+
=> JsonConvert.DeserializeObject<T>(value, DeserializeSettings);
60+
61+
/// <summary>
62+
/// Serializes the value to JSON with default serialization settings.
63+
/// </summary>
64+
/// <param name="value">Serialization target value</param>
65+
/// <returns>The serialized JSON string</returns>
66+
public static string SerializeObject(object value)
67+
=> SerializeObject(value, DefaultSerializationSettings);
68+
69+
/// <summary>
70+
/// Serializes the value to JSON with HTML escaping serialization settings.
71+
/// </summary>
72+
/// <param name="value">Serialization target value</param>
73+
/// <returns>The serialized JSON string</returns>
74+
public static string HtmlEscapedSerializeObject(object value)
75+
=> SerializeObject(value, HtmlEscapedSerializationSettings);
76+
77+
/// <summary>
78+
/// Serializes the value to JSON with custom serialization settings.
79+
/// </summary>
80+
/// <param name="value">Serialization target value</param>
81+
/// <param name="jsonSerializerSettings">JSON serialization settings</param>
82+
/// <returns>The serialized JSON string</returns>
83+
public static string SerializeObject(object value, JsonSerializerSettings jsonSerializerSettings)
84+
=> RemoveAllButFirstContext(JsonConvert.SerializeObject(value, jsonSerializerSettings));
85+
86+
private static string RemoveAllButFirstContext(string json)
87+
{
88+
if (json.IndexOf(ContextPropertyJson, StringComparison.Ordinal) != -1)
89+
{
90+
var stringBuilder = new StringBuilder(json);
91+
var startIndex = ContextPropertyJson.Length + 1; // We add the one to represent the opening curly brace.
92+
stringBuilder.Replace(ContextPropertyJson, string.Empty, startIndex, stringBuilder.Length - startIndex);
93+
return stringBuilder.ToString();
94+
}
95+
96+
return json;
97+
}
98+
}
99+
}

Source/Schema.NET/Thing.Partial.cs

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,19 @@
11
namespace Schema.NET
22
{
3-
using System.Collections.Generic;
4-
using System.Text;
53
using Newtonsoft.Json;
6-
using Newtonsoft.Json.Converters;
74

85
/// <summary>
96
/// The most generic type of item.
107
/// </summary>
118
public partial class Thing : JsonLdObject
129
{
13-
private const string ContextPropertyJson = "\"@context\":\"https://schema.org\",";
14-
15-
/// <summary>
16-
/// Default serializer settings used.
17-
/// </summary>
18-
private static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings()
19-
{
20-
Converters = new List<JsonConverter>()
21-
{
22-
new StringEnumConverter(),
23-
},
24-
DefaultValueHandling = DefaultValueHandling.Ignore,
25-
NullValueHandling = NullValueHandling.Ignore,
26-
};
27-
28-
/// <summary>
29-
/// Serializer settings used when trying to avoid XSS vulnerabilities where user-supplied data is used
30-
/// and the output of the serialization is embedded into a web page raw.
31-
/// </summary>
32-
private static readonly JsonSerializerSettings HtmlEscapedSerializerSettings = new JsonSerializerSettings()
33-
{
34-
Converters = new List<JsonConverter>()
35-
{
36-
new StringEnumConverter(),
37-
},
38-
DefaultValueHandling = DefaultValueHandling.Ignore,
39-
NullValueHandling = NullValueHandling.Ignore,
40-
StringEscapeHandling = StringEscapeHandling.EscapeHtml,
41-
};
42-
4310
/// <summary>
4411
/// Returns the JSON-LD representation of this instance.
4512
/// </summary>
4613
/// <returns>
4714
/// A <see cref="string" /> that represents the JSON-LD representation of this instance.
4815
/// </returns>
49-
public override string ToString() => this.ToString(SerializerSettings);
16+
public override string ToString() => SchemaSerializer.SerializeObject(this);
5017

5118
/// <summary>
5219
/// Returns the JSON-LD representation of this instance.
@@ -58,7 +25,7 @@ public partial class Thing : JsonLdObject
5825
/// <returns>
5926
/// A <see cref="string" /> that represents the JSON-LD representation of this instance.
6027
/// </returns>
61-
public string ToHtmlEscapedString() => this.ToString(HtmlEscapedSerializerSettings);
28+
public string ToHtmlEscapedString() => SchemaSerializer.HtmlEscapedSerializeObject(this);
6229

6330
/// <summary>
6431
/// Returns the JSON-LD representation of this instance using the <see cref="JsonSerializerSettings"/> provided.
@@ -73,14 +40,6 @@ public partial class Thing : JsonLdObject
7340
/// A <see cref="string" /> that represents the JSON-LD representation of this instance.
7441
/// </returns>
7542
public string ToString(JsonSerializerSettings serializerSettings) =>
76-
RemoveAllButFirstContext(JsonConvert.SerializeObject(this, serializerSettings));
77-
78-
private static string RemoveAllButFirstContext(string json)
79-
{
80-
var stringBuilder = new StringBuilder(json);
81-
var startIndex = ContextPropertyJson.Length + 1; // We add the one to represent the opening curly brace.
82-
stringBuilder.Replace(ContextPropertyJson, string.Empty, startIndex, stringBuilder.Length - startIndex);
83-
return stringBuilder.ToString();
84-
}
43+
SchemaSerializer.SerializeObject(this, serializerSettings);
8544
}
8645
}

Source/Schema.NET/ValuesJsonConverter.cs

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,29 @@ private static bool TryProcessTokenAsType(JsonReader reader, Type targetType, ou
318318
}
319319
else if (targetType == typeof(DateTime))
320320
{
321-
success = DateTime.TryParse(valueString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localResult);
322-
result = localResult;
321+
if (DateTimeHelper.TryParseMSDateTime(valueString, out var localResult))
322+
{
323+
success = true;
324+
result = localResult;
325+
}
326+
else
327+
{
328+
success = DateTime.TryParse(valueString, CultureInfo.InvariantCulture, DateTimeStyles.None, out localResult);
329+
result = localResult;
330+
}
323331
}
324332
else if (targetType == typeof(DateTimeOffset))
325333
{
326-
success = DateTimeOffset.TryParse(valueString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localResult);
327-
result = localResult;
334+
if (DateTimeHelper.TryParseMSDateTimeOffset(valueString, out var localResult))
335+
{
336+
success = true;
337+
result = localResult;
338+
}
339+
else if (DateTimeHelper.ContainsTimeOffset(valueString))
340+
{
341+
success = DateTimeOffset.TryParse(valueString, CultureInfo.InvariantCulture, DateTimeStyles.None, out localResult);
342+
result = localResult;
343+
}
328344
}
329345
else if (targetType == typeof(TimeSpan))
330346
{
@@ -367,19 +383,6 @@ private static bool TryProcessTokenAsType(JsonReader reader, Type targetType, ou
367383
success = true;
368384
}
369385
}
370-
else if (tokenType == JsonToken.Date)
371-
{
372-
if (targetType == typeof(DateTime) && reader.Value is DateTimeOffset dateTimeOffset)
373-
{
374-
result = dateTimeOffset.UtcDateTime;
375-
success = true;
376-
}
377-
else if (targetType == typeof(DateTimeOffset) && reader.Value is DateTime dateTime)
378-
{
379-
result = new DateTimeOffset(dateTime);
380-
success = true;
381-
}
382-
}
383386

384387
value = result;
385388
return success;

0 commit comments

Comments
 (0)