Skip to content

Commit

Permalink
Follow root / parent JSON-LD context when parsing nested objects (res…
Browse files Browse the repository at this point in the history
…olves #152) (#170)

* Refactor JsonLDContext to support child -> parent chaining.
* Link to parent context when parsing nested objects.
* Skip context entries in parent when serializing nested objects.
* Simplify ASLink conversion from string form.
  • Loading branch information
warriordog authored Jan 4, 2024
1 parent 168b85e commit 2d7e698
Show file tree
Hide file tree
Showing 12 changed files with 1,046 additions and 125 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,32 @@ public class JsonLDContextConverter : JsonConverter<JsonLDContext>
{
/// <inheritdoc />
public override JsonLDContext? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
=> reader.TokenType switch
{
case JsonTokenType.Null:
return null;

case JsonTokenType.String:
case JsonTokenType.StartObject:
JsonTokenType.Null => null,
JsonTokenType.String => new JsonLDContext
{
var context = ReadContext(ref reader, options);
return new JsonLDContext
{
context
};
}

case JsonTokenType.StartArray:
ReadContext(ref reader, options)
},
JsonTokenType.StartObject => new JsonLDContext
{
var context = new JsonLDContext();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
var contextObj = ReadContext(ref reader, options);
context.Add(contextObj);
}

return context;
}

default:
throw new JsonException($"Cannot deserialize {reader.TokenType} as @context field");
ReadContext(ref reader, options)
},
JsonTokenType.StartArray => ReadContextArray(ref reader, options),
_ => throw new JsonException($"Cannot deserialize {reader.TokenType} as @context field")
};

private static JsonLDContext ReadContextArray(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
var context = new JsonLDContext();

while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
var contextObj = ReadContext(ref reader, options);
context.Add(contextObj);
}

return context;
}

private static JsonLDContextObject ReadContext(ref Utf8JsonReader reader, JsonSerializerOptions options)
Expand All @@ -65,17 +60,19 @@ private static JsonLDContextObject ReadContext(ref Utf8JsonReader reader, JsonSe
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, JsonLDContext value, JsonSerializerOptions options)
{
if (value.Count == 1)
var contexts = value.LocalContexts.ToList();
if (contexts.Count == 1)
{
var context = value.First();
var context = contexts.Single();
JsonSerializer.Serialize(writer, context, options);
}
else
{
writer.WriteStartArray();
foreach (var context in value)

foreach (var context in contexts)
JsonSerializer.Serialize(writer, context, options);

writer.WriteEndArray();
}
}
Expand Down
35 changes: 20 additions & 15 deletions Source/ActivityPub.Types/Conversion/Converters/TypeMapConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ namespace ActivityPub.Types.Conversion.Converters;
/// <inheritdoc />
public class TypeMapConverter : JsonConverter<TypeMap>
{
/// <summary>
/// Chain of contexts that inherit from each other.
/// If a value is present, then it should be the parent of the current object's context.
/// </summary>
private NestedContextStack NestedContextStack { get; } = new();

/// <inheritdoc />
public override TypeMap Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Expand All @@ -33,29 +39,28 @@ public override TypeMap Read(ref Utf8JsonReader reader, Type typeToConvert, Json
};
}

private static TypeMap ReadString(JsonElement jsonElement)
private TypeMap ReadString(JsonElement jsonElement)
{
// Read Link entity
var link = new ASLinkEntity
// Construct the JSON-LD context
var parentContext = NestedContextStack.Peek();
var context = JsonLDContext.CreateASContext(parentContext);

// Create and prep the type graph
var typeMap = new TypeMap(context);
typeMap.Extend<ASType, ASTypeEntity>();

// Read Link from string
var link = new ASLink(typeMap)
{
HRef = jsonElement.GetString()!
};

// Create TypeGraph around it
var context = JsonLDContext.CreateASContext();
var types = new List<string> { ASLink.LinkType };
var typeMap = new TypeMap(context, types);

// Attach entities
typeMap.AddEntity(link);
typeMap.AddEntity(new ASTypeEntity());

return typeMap;
return link.TypeMap;
}

private static TypeMap ReadObject(JsonElement jsonElement, JsonSerializerOptions options)
private TypeMap ReadObject(JsonElement jsonElement, JsonSerializerOptions options)
{
var typeGraphReader = new TypeGraphReader(options, jsonElement);
var typeGraphReader = new TypeGraphReader(options, jsonElement, NestedContextStack);
return new TypeMap(typeGraphReader);
}

Expand Down
7 changes: 6 additions & 1 deletion Source/ActivityPub.Types/Internal/CompositeASType.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System.Collections;

namespace ActivityPub.Types.Internal;

/// <summary>
/// Represents the AS type name of a composite object.
/// More-specific subtypes will "shadow" (replace) more-generic base types.
/// </summary>
internal class CompositeASType
internal class CompositeASType : IEnumerable<string>
{
private readonly HashSet<string> _allASTypes = [];
private readonly HashSet<string> _flatASTypes = [];
Expand Down Expand Up @@ -61,4 +63,7 @@ public void AddRange(IEnumerable<string> asTypes)
Add(asType);
}
}

public IEnumerator<string> GetEnumerator() => Types.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
31 changes: 31 additions & 0 deletions Source/ActivityPub.Types/Internal/NestedContextStack.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

using ActivityPub.Types.Util;

namespace ActivityPub.Types.Internal;

internal class NestedContextStack
{
private ThreadLocal<Stack<IJsonLDContext>> ContextStack { get; } = new
(
() => new Stack<IJsonLDContext>(),
false
);

public void Push(IJsonLDContext context)
{
ContextStack.Value!.Push(context);
}

public IJsonLDContext? Peek()
{
ContextStack.Value!.TryPeek(out var value);
return value;
}

public void Pop()
{
ContextStack.Value!.TryPop(out _);
}
}
107 changes: 64 additions & 43 deletions Source/ActivityPub.Types/Internal/TypeGraphReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace ActivityPub.Types.Internal;

internal interface ITypeGraphReader
{
public JsonLDContext GetASContext();
public List<string> GetASTypes();
public JsonLDContext ASContext { get; }
public CompositeASType ASTypes { get; }

public bool TryReadEntity<TModel, TEntity>(TypeMap typeMap, [NotNullWhen(true)] out TEntity? entity)
where TModel : ASType, IASModel<TModel, TEntity>
Expand All @@ -23,107 +23,128 @@ public bool TryReadEntity<TModel>(TypeMap typeMap, [NotNullWhen(true)] out ASEnt

internal class TypeGraphReader : ITypeGraphReader
{
public JsonLDContext ASContext { get; }
public CompositeASType ASTypes { get; }

private readonly JsonSerializerOptions _jsonOptions;
private readonly JsonElement _sourceElement;
private readonly NestedContextStack _nestedContextStack;

private readonly JsonLDContext _context;
private readonly HashSet<string> _asTypes;

public TypeGraphReader(JsonSerializerOptions jsonOptions, JsonElement sourceElement)
public TypeGraphReader(JsonSerializerOptions jsonOptions, JsonElement sourceElement, NestedContextStack nestedContextStack)
{
_jsonOptions = jsonOptions;
_sourceElement = sourceElement;
_nestedContextStack = nestedContextStack;

if (sourceElement.ValueKind != JsonValueKind.Object)
throw new ArgumentException($"{nameof(TypeGraphReader)} can only convert JSON objects. Input is of type {sourceElement.ValueKind}.", nameof(sourceElement));

_context = ReadASContext(_sourceElement, _jsonOptions);
_asTypes = ReadASTypes(_sourceElement, _jsonOptions);
ASContext = ReadASContext(_sourceElement, _jsonOptions);
ASTypes = ReadASTypes(_sourceElement, _jsonOptions);
}

public JsonLDContext GetASContext()
=> _context.Clone();

public List<string> GetASTypes()
=> _asTypes.ToList();


public bool TryReadEntity<TModel, TEntity>(TypeMap typeMap, [NotNullWhen(true)] out TEntity? entity)
where TModel : ASType, IASModel<TModel, TEntity>
where TEntity : ASEntity<TModel, TEntity>, new()
{
var shouldConvert = TModel.ShouldConvertFrom(_sourceElement, typeMap) ?? ShouldConvertObject<TModel>();
if (shouldConvert)
{
entity = _sourceElement.Deserialize<TEntity>(_jsonOptions)
?? throw new JsonException($"Failed to deserialize {TModel.EntityType} - JsonElement.Deserialize returned null");
return true;
}
try
{
_nestedContextStack.Push(ASContext);

var shouldConvert = TModel.ShouldConvertFrom(_sourceElement, typeMap) ?? ShouldConvertObject<TModel>();
if (shouldConvert)
{
entity = _sourceElement.Deserialize<TEntity>(_jsonOptions)
?? throw new JsonException($"Failed to deserialize {TModel.EntityType} - JsonElement.Deserialize returned null");
return true;
}

entity = null;
return false;
entity = null;
return false;
}
finally
{
_nestedContextStack.Pop();
}
}

public bool TryReadEntity<TModel>(TypeMap typeMap, [NotNullWhen(true)] out ASEntity? entity)
where TModel : ASType, IASModel<TModel>
{
var shouldConvert = TModel.ShouldConvertFrom(_sourceElement, typeMap) ?? ShouldConvertObject<TModel>();
if (shouldConvert)
{
entity = (ASEntity?)_sourceElement.Deserialize(TModel.EntityType, _jsonOptions)
?? throw new JsonException($"Failed to deserialize {TModel.EntityType} - JsonElement.Deserialize returned null");
return true;
}
try
{
_nestedContextStack.Push(ASContext);

var shouldConvert = TModel.ShouldConvertFrom(_sourceElement, typeMap) ?? ShouldConvertObject<TModel>();
if (shouldConvert)
{
entity = (ASEntity?)_sourceElement.Deserialize(TModel.EntityType, _jsonOptions)
?? throw new JsonException($"Failed to deserialize {TModel.EntityType} - JsonElement.Deserialize returned null");
return true;
}

entity = null;
return false;
entity = null;
return false;
}
finally
{
_nestedContextStack.Pop();
}
}

private bool ShouldConvertObject<TModel>()
where TModel : ASType, IASModel<TModel>
{
// Check context first
if (!_context.IsSupersetOf(TModel.DefiningContext))
if (!ASContext.Contains(TModel.DefiningContext))
return false;

// If this is a nameless entity, then we're done.
if (TModel.ASTypeName == null)
return true;

// Next, we need to check the AS type name.
if (_asTypes.Contains(TModel.ASTypeName))
if (ASTypes.Contains(TModel.ASTypeName))
return true;

// Finally, we need to check the AS type name of all *derived* types.
// Otherwise, an object like {"type":"Create"} would not be detected as a type of Activity.
// This is non-trivial, as we must do this without reflection (this code is on the hot path).
// Fortunately, most of the work is done for us by ASNameTree.
var derivedTypes = TModel.DerivedTypeNames;
return derivedTypes != null && _asTypes.Overlaps(derivedTypes);
return derivedTypes != null && ASTypes.AllTypes.Overlaps(derivedTypes);
}

private static JsonLDContext ReadASContext(JsonElement element, JsonSerializerOptions options)
private JsonLDContext ReadASContext(JsonElement element, JsonSerializerOptions options)
{
var parentContext = _nestedContextStack.Peek();

// Try to get the context property.
if (!element.TryGetProperty("@context", out var contextProp))
// If missing then use default.
return JsonLDContext.CreateASContext();
return JsonLDContext.CreateASContext(parentContext);

// Convert context
return contextProp.Deserialize<JsonLDContext>(options)
var context = contextProp.Deserialize<JsonLDContext>(options)
?? throw new JsonException("Can't convert TypeMap - \"@context\" property is null");
context.Add(JsonLDContextObject.ActivityStreams);
context.SetParent(parentContext);
return context;
}

private static HashSet<string> ReadASTypes(JsonElement element, JsonSerializerOptions options)
private static CompositeASType ReadASTypes(JsonElement element, JsonSerializerOptions options)
{
// An object without the types field is just "object"
if (!element.TryGetProperty("type", out var typeProp))
return [ASObject.ObjectType];

// Everything thing else must be converted
var types = typeProp.Deserialize<HashSet<string>>(options)
var allTypes = typeProp.Deserialize<HashSet<string>>(options)
?? throw new JsonException("Can't convert TypeMap - \"type\" is null");
types.Add(ASObject.ObjectType);
return types;

var compositeType = new CompositeASType();
compositeType.AddRange(allTypes);
compositeType.Add(ASObject.ObjectType);
return compositeType;
}
}
Loading

0 comments on commit 2d7e698

Please sign in to comment.