Search code examples
c#jsonsystem.text.json.net-6.0activitypub

use System.Text.Json to serialize an Object as a single String conditionally


I'm working on an ActivityPub implimentation in c#, and sometimes links are "strings" like a url link, and sometimes links are objects with a Link sub-type. (Link : Entity)

I'm wondering if there's a possible way to use System.Text.Json to serialize a Link object as a string if a certain set of conditions are true (just write one string to the writer), and write the whole default object to the writer if the condition is not true.

I have tried following this solution: How to use default serialization in a custom System.Text.Json JsonConverter?, which still works on the code fiddle, but does not work for my implimentation and I'm not too sure why.

Does anyone know how I might debug this, or a better way to go about making sure Link : Entity objects can serialize into strings on occasion?

With that I get the following error:enter image description here

(in this case I have even tried to add the fetched default ctor to the modifiedOptions) Reguardless, it says that there's no data mapped for the Link class. I have also tried adding the JsonSerializeable attribute directly to the Link class.

The Error: Metadata for type 'ActivityPub.Types.Link' was not provided to the serializer. The serializer method used does not support reflection-based creation of serialization-related type metadata. If using source generation, ensure that all root types passed to the serializer have been indicated with 'JsonSerializableAttribute', along with any types that might be serialized polymorphically.

My base code library: https://github.com/Meep-Tech/ActivityHub.Net/tree/collapse_links_during_serialization

The test code:

static void Main(string[] args) {
      Settings.DefaultContext = new Link("ActivityPub.Net.Testing");

      var testObject = new Object {
        Type = "Test",
        At = new Link("/terry") {
          Rels = new string[] {
            "test",
            "test2"
          }
        },
        Attribution = "/meep",
        Audience = new Link("/all") {
          Rel = "test"
        }
      };

      string json = testObject
        .Serialize();

      System.IO.File.WriteAllLines(
        "test.json",
        new[] { json }
      );

      Object @object = json.DeSerializeEntity<Object>();
      System.IO.File.WriteAllLines(
        "test1.json",
        new[] { @object.ToString() }
      );
    }

Solution

  • In my original version of DefaultConverterFactory<T>, I cached the default converter because, in its documentation How to write custom converters for JSON serialization (marshalling) in .NET, Microsoft recommends, when serializing a complex object, to cache any required converters for performance reasons:

    public DictionaryEnumConverterInner(JsonSerializerOptions options)
    {
       // For performance, use the existing converter if available.
       _valueConverter = (JsonConverter<TValue>)options
           .GetConverter(typeof(TValue));
    
       // Cache the key and value types.
       _keyType = typeof(TKey);
       _valueType = typeof(TValue);
    }
    

    However, this has proven problematic for several reasons:

    1. When serializing a polymorphic value with a declared type of object, a non-functional converter is returned by GetConverter().

    2. When serializing numeric values, the converter returned ignores the NumberHandling setting.

    3. And now it appears you may have encountered a third problem: when using compile-time serializer source generation, the converter returned may not work.

    That is enough problems to warrant ignoring Microsoft's recommendation. Simply DefaultConverter<T> as follows:

    public abstract class DefaultConverterFactory<T> : JsonConverterFactory
    {
        class DefaultConverter : JsonConverter<T>
        {
            readonly JsonSerializerOptions modifiedOptions;
            readonly DefaultConverterFactory<T> factory;
    
            public DefaultConverter(JsonSerializerOptions options, DefaultConverterFactory<T> factory)
            {
                this.factory = factory;
                this.modifiedOptions = options.CopyAndRemoveConverter(factory.GetType());
            }
    
            public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => factory.Write(writer, value, modifiedOptions);
    
            public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => factory.Read(ref reader, typeToConvert, modifiedOptions);
        }
    
        protected virtual T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions modifiedOptions)
            => (T)JsonSerializer.Deserialize(ref reader, typeToConvert, modifiedOptions);
    
        protected virtual void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions modifiedOptions) 
            => JsonSerializer.Serialize(writer, value, modifiedOptions);
    
        public override bool CanConvert(Type typeToConvert) => typeof(T) == typeToConvert;
    
        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new DefaultConverter(options, this);
    }
    
    public static class JsonSerializerExtensions
    {
        public static JsonSerializerOptions CopyAndRemoveConverter(this JsonSerializerOptions options, Type converterType)
        {
            var copy = new JsonSerializerOptions(options);
            for (var i = copy.Converters.Count - 1; i >= 0; i--)
                if (copy.Converters[i].GetType() == converterType)
                    copy.Converters.RemoveAt(i);
            return copy;
        }
    }
    

    Then, in any classes derived from DefaultConverterFactory<T> such as this one here, remove the final parameter JsonConverter<T> defaultConverter from Read() and Write(), and your code should now work.

    (Incidentally, you seem to be using [JsonSerializable(typeof(Link))] wrongly. You are applying it to your model class Link but, according to the docs, it should be applied to some subclass of JsonSerializerContext for your model -- not the model itself.)