warriordog / ActivityPubSharp

Modular implementation of ActivityPub in C#
https://warriordog.github.io/ActivityPubSharp/
Mozilla Public License 2.0
45 stars 10 forks source link

Research: design API for TypeMap concept #73

Closed warriordog closed 1 year ago

warriordog commented 1 year ago

Part of #71. Design the API surface considering application, library, and extension viewpoints. Include sample usage for comparison.

Prototype API (completes research, but may be changed later):


public class TypeMap
{
    /// <summary>
    /// Live set of all unique ActivityStreams types represented by this object.
    /// </summary>
    /// <seealso cref="DotNetTypes"/>
    public IReadOnlySet<string> ASTypes => throw new NotImplementedException();

    /// <summary>
    /// Live set of all unique .NET types represented by the object.
    /// This may be a subset of ASTypes.
    /// </summary>
    /// <seealso cref="ASTypes"/>
    public IReadOnlySet<Type> DotNetTypes => throw new NotImplementedException();

    /// <summary>
    /// JSON-LD context in use for this object graph.
    /// </summary>
    public JsonLDContext LDContexts => throw new NotImplementedException();

    /// <summary>
    /// Checks if the object contains a particular type entity.
    /// </summary>
    public bool IsEntity<T>() where T : ASBase
        => throw new NotImplementedException();

    /// <summary>
    /// Checks if the object contains a particular type entity.
    /// If so, then the instance of that type is extracted and returned.
    /// </summary>
    /// <seealso cref="IsEntity{T}()" />
    /// <seealso cref="AsEntity{T}" />
    public bool IsEntity<T>([NotNullWhen(true)] out T? instance) where T : ASBase
        => throw new NotImplementedException();

    /// <summary>
    /// Gets an entity representing the object as type T.
    /// </summary>
    /// <remarks>
    /// This function will not extend the object to include a new type.
    /// To safely convert to an instance that *might* be present, use Is().
    /// </remarks>
    /// <seealso cref="IsEntity{T}(out T?)" />
    /// <throws cref="ArgumentException">If the object is not of type T</throws>
    public T AsEntity<T>() where T : ASBase
        => throw new NotImplementedException();

    /// <summary>
    /// Checks if the graph contains a particular type.
    /// </summary>
    public bool IsType<T>() where T : ASType
        => throw new NotImplementedException();

    /// <summary>
    /// Checks if the graph contains a particular type.
    /// If so, then the instance of that type is extracted and returned.
    /// </summary>
    /// <seealso cref="IsType{T}()" />
    /// <seealso cref="AsType{T}" />
    public bool IsType<T>([NotNullWhen(true)] out T? instance) where T : ASType
        => throw new NotImplementedException();

    /// <summary>
    /// Gets an object representing the graph as type T.
    /// </summary>
    /// <remarks>
    /// This function will not extend the object to include a new type.
    /// To safely convert to an instance that *might* be present, use Is().
    /// </remarks>
    /// <seealso cref="IsType{T}(out T?)" />
    /// <throws cref="ArgumentException">If the object is not of type T</throws>
    public T AsType<T>() where T : ASType
        => throw new NotImplementedException();

    /// <summary>
    /// Adds a new typed instance to the object.
    /// </summary>
    /// <remarks>
    /// This method is internal, as it should only be called by <see cref="ASBase"/> constructor.
    /// User code should instead add a new type by passing an existing TypeMap into the constructor.
    /// This is not a technical limitation, but rather an intentional choice to avoid merge logic by making object graphs append-only.
    /// </remarks>
    /// <throws cref="InvalidOperationException">If an object of this type already exists in the graph</throws>
    internal void Add<T>(T instance) where T : ASBase
        => throw new NotImplementedException();
}
warriordog commented 1 year ago

Idea that needs some thought. We can simplify the API and implementation if the semantics of As<T>() are changed from this:

  • If T is a type in the object, then return the object represented as T
  • Otherwise, throw an exception

To this:

  • If T is a type in the object, then return the object represented as T
  • Otherwise, extend the object to include T, then return the object represented as T

This makes a very minimal and consistent API surface, with no runtime exceptions outside of genuine errors. Extending an object is also very natural, especially if the incoming object may or may not already be extended.

Problem: What if the new extension type cannot be constructed? Do we need a new static virtual interface for this? We can't use [ASTypeKey] since DI is not available to provide cached metadata.

warriordog commented 1 year ago

Current thinking is to not support de-extending an object. That opens a whole can of worms and is only relevant in some extreme edge cases. If it really matters, then the user will just need to manually construct a new object.

warriordog commented 1 year ago

Another idea - since TypeMap has to be mutable, we embrace that. No-args constructor, and expose an internal Add() method to add a type. ASTypeConverter can construct the instances and pass them in sequentially, and TypeMap will link up all the internal references.

warriordog commented 1 year ago

Alternative to this above idea:

This leaves the user responsible for creating new extensions, while still being fairly ergonomic. The big challenge is validation. We need to check for and prevent all of these erroneous cases:

Big issue: ❗ This might require user to construct a SubWhatever class, and that's far from ideal.

warriordog commented 1 year ago

If needed, here's a type for the "wrap and extend base type" concept:

// Put this on ASType, then interleave TThis and TBase throughout all types.
public interface IBaseExtender<out TThis, in TBase>
    where TBase : ASType
    where TThis : TBase, IBaseExtender<TThis, TBase>
{
    public static abstract TThis ExtendBase(TBase baseInstance);
}
warriordog commented 1 year ago

Another big idea to consider: what if we combined the sub and main types?

Basically, make all of the types wrap a base instance, then there's no need for a sub type. As a bonus, the virtual redirects themselves could be inherited to avoid duplication. The division would happen between constructors instead of at the type level. The existing no-args and type-only constructors can directly construct the base type, and a new "extension" constructor will accept an existing instance to wrap.

ASType would require special handling as it doesn't have a base, but that's fine. Another issue - Required properties are a bit of a pain with this. They work, but you have to include the redundant initializer block in all subtypes.

Actually this might be too insane. I think I'm just implementing Evolutions in C# which is... not what I planned to do with my Saturday morning.

warriordog commented 1 year ago

I guess while we're on the topic of high-level refactors, we could also consider dropping the object-oriented model entirely and using composition. Instead of "extends", we "link to". That would really help for edge cases like OrderedCollectionPage, which is meant to be both a CollectionPage and an OrderedCollection.

The downside is that since we don't have inheritance, subtypes can't override behavior. We would also have to hack in the synthetic types, like ASActor.

warriordog commented 1 year ago

As of latest change (LDContexts property), this now depends on #37 and possibly #38.

warriordog commented 1 year ago

Completed with #74