dotansimha / graphql-code-generator-community

MIT License
116 stars 152 forks source link

[c-sharp] Add an implementation for GQL Union Types and Interfaces #149

Open cicciodm opened 3 years ago

cicciodm commented 3 years ago

Is your feature request related to a problem? Please describe.

The c-sharp plugin currently does not provide an implementation of GQL Union types and Interfaces. I will refer to both of these as Composition Types.

Consider the following schema definition:

union Vehicle = Airplane | Car

type Airplane {
    wingspan: Int
}

type Car {
    licensePlate: String
}

With the current implementation, these types are simply ignored. This makes the plugin not really usable for real-world scenarios, such as generating type for a complex API currently serving many real users.

Describe the solution you'd like On a personal fork of graphql-code-generator I have implemented a way to model Composition Types in C#.

The language has no concept of Union Types, so I took inspiration from Typescript to model it. Using the above example, this is how it works.

The Union Type is modelled with an interface named as the type itself, and an Enum called {name}Kind with values representing each concrete implementation of the Union Type.

/// <summary>
/// An enum representing the possible values of Vehicle
/// </summary>
public enum VehicleKind {
  /// <summary>
  /// Airplane
  /// </summary>
  Airplane,
  /// <summary>
  /// Car
  /// </summary>
  Car
}

public interface Vehicle {
  /// <summary>
  /// Kind is used to discriminate by type instances of this interface
  /// </summary>
  VehicleKind Kind { get; }
}

Each of the concrete type will implement the generic interface, including an autogenerated getter, which will return the correct value of Kind.

#region Airplane
public class Airplane {
  #region members
  /// <summary>
  /// The kind used to discriminate the union type
  /// </summary>

  VehicleKind Vehicle.Kind { get { return VehicleKind.Airplane; } }

  [JsonProperty("wingspan")]
  public int? wingspan { get; set; }
  #endregion
}
#endregion

#region Car
public class Car {
  #region members
  /// <summary>
  /// The kind used to discriminate the union type
  /// </summary>

  VehicleKind Vehicle.Kind { get { return VehicleKind.Car; } }

  [JsonProperty("licensePlate")]
  public string licensePlate { get; set; }
  #endregion
}
#endregion

When faced with a Veichle, devs can then check on the VeichleKind on their object, before safely casting the object to the appropriate type.

Veichle genericVeichle = query.data.veichle;

if (genericVeichle.Kind == VeichleKind.Airplane)
{
    var wingSpan = (genericVeichle as Airplane).wingspan;
}

Another block necessary to make this implementation work is the addition of a custom implementation for a Newtonsoft JSONConverter for correctly Deserializing fields that are of any Composition type (UnionType or Interface), as simply calling serializer.Deserialize<GeneratedGQLQueryType>() would otherwise fail with Cannot instantiate item of type Veichle

The Custom Converters (one for fields of CompositionType type, and one for list of Composition Type objects) work by inspecting the __typename of the object currently being deserialised. Based on the __typename, the appropriate Generated Type will be found using introspection, and an appropriate ToObject method will be created, with the correct type parameter.

/// <summary>
/// Given __typeName returns JToken::ToObject[__typeName]. (via cache to improve performance)
/// </summary>
/// <param name="typeName">The __typeName</param>
/// <returns>JToken::ToObject[__typeName]</returns>
public static Func<JToken, object> GetToObjectMethodForTargetType(string typeName)
{
    if (!ToObjectForTypenameCache.ContainsKey(typeName))
    {
        // Get the type corresponding to the typename
        Type targetType = typeof(YammerGQLTypes).Assembly
            .GetTypes()
            .ToList()
            .Where(t => t.Name == typeName)
            .FirstOrDefault();

        // Create a parametrised ToObject method using targetType as <TypeArgument>
        var method = typeof(JToken).GetMethods()
            .Where(m => m.Name == "ToObject" && m.IsGenericMethod && m.GetParameters().Length == 0).FirstOrDefault();
        var genericMethod = method.MakeGenericMethod(targetType);
        var toObject = (Func<JToken, object>)genericMethod.CreateDelegate(Expression.GetFuncType(typeof(JToken), typeof(object)));
        ToObjectForTypenameCache[typeName] = toObject;
    }

    return ToObjectForTypenameCache[typeName];
}

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
    if (reader.TokenType == JsonToken.Null)
    {
        return null;
    }

    var loadedObject = JObject.Load(reader);
    var typeNameObject = loadedObject["__typename"];

    if (typeNameObject == null)
    {
        throw new JsonWriterException($"CompositionTypeConverter Exception: missing __typeName field when parsing {objectType.Name}. Requesting the __typename field is required for converting Composition Types");
    }

    var typeName = loadedObject["__typename"].Value<string>();
    var toObject = GetToObjectMethodForTargetType(typeName);
    object objectParsed = toObject(loadedObject);

    return objectParsed;
}

All fields that require the Custom Converter to be parsed correctly, are automatically marked with the [JsonConverter(typeof(UnionType[List]Converter))] attribute.

Describe alternatives you've considered No alternative considered as of now. I have tried to push the CustomConverter tag to the types to be parsed, as opposed to the fields of those types, but that approach was not successful.

Additional context A version of this implementation is currently being used in production, and has several hundred thousand hits per day, with near perfect success rate.

Link to the fork: https://github.com/cicciodm/graphql-code-generator/tree/c%23-union-types/packages/plugins/c-sharp/c-sharp/src

smaktacular commented 3 years ago

NICE! Exactly what I was looking for. It seems to work as expected. Any Idea why this is happening? grafik

The name of the enum should be IBaseIfaceKind

ardatan commented 3 years ago

We'd love to accept a PR if someone wants to work on this.

cicciodm commented 3 years ago

@ardatan Pr can be found here: https://github.com/dotansimha/graphql-code-generator/pull/6862

smaktacular commented 3 years ago

NICE! Exactly what I was looking for. It seems to work as expected. Any Idea why this is happening? grafik

The name of the enum should be IBaseIfaceKind

The spaces in the interface name resulted from the change-case-all#capitalCase setting. @ardatan Do you know if this counts as bug or is it working as intended?