Make names of named tuples appear in serialized JSON responses

11.7k views Asked by At

Situation: I have multiple Web service API calls that deliver object structures. Currently, I declare explicit types to bind those object structures together. For the sake of simplicity, here's an example:

[HttpGet]
[ProducesResponseType(typeof(MyType), 200)]
public MyType TestOriginal()
{
    return new MyType { Speed: 5.0, Distance: 4 };
}

Improvement: I have loads of these custom classes like MyType and would love to use a generic container instead. I came across named tuples and can successfully use them in my controller methods like this:

[HttpGet]
[ProducesResponseType(typeof((double speed, int distance)), 200)]
public (double speed, int distance) Test()
{
    return (speed: 5.0, distance: 4);
}

Problem I am facing is that the resolved type is based on the underlying Tuple which contains these meaningless properties Item1, Item2 etc. Example:

enter image description here

Question: Has anyone found a solution to get the names of the named tuples serialized into my JSON responses? Alternatively, has anyone found a generic solution that allows to have a single class/representation for random structures that can be used so that the JSON response explicitly names what it contains.

5

There are 5 answers

3
Fabio On

You have a little bid conflicting requirements

Question:

I have loads of these custom classes like MyType and would love to use a generic container instead

Comment:

However, what type would I have to declare in my ProducesResponseType attribute to explicitly expose what I am returning

Based on above - you should stay with types you already have. Those types provide valuable documentation in your code for other developers/reader or for yourself after few months.

From point of readability

[ProducesResponseType(typeof(Trip), 200)]

will be better then

[ProducesResponseType(typeof((double speed, int distance)), 200)]

From point of maintainability
Adding/removing property need to be done only in one place. Where with generic approach you will need to remember update attributes too.

2
divay pandey On

The simplest solution is using dynamic code, i.e. C#'s ExpandoObject to wrap your response in the format you expect the API to have

    public JsonResult<ExpandoObject> GetSomething(int param)
    {
        var (speed, distance) = DataLayer.GetData(param);
        dynamic resultVM = new ExpandoObject();
        resultVM.speed= speed;
        resultVM.distance= distance;
        return Json(resultVM);
    }

The return type of "GetData" is

(decimal speed, int distance)

This gives a Json response in the way you expect it to

2
Prudvi On

Make use of anonymous object instead.

(double speed, int distance) = (5.0, 4);
return new { speed, distance };
0
anatol On

For serializing response just use any custom attribute on action and custom contract resolver (this is only solution, unfortunately, but I'm still looking for any more elegance one).

Attribute:

public class ReturnValueTupleAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        var content = actionExecutedContext?.Response?.Content as ObjectContent;
        if (!(content?.Formatter is JsonMediaTypeFormatter))
        {
            return;
        }

        var names = actionExecutedContext
            .ActionContext
            .ControllerContext
            .ControllerDescriptor
            .ControllerType
            .GetMethod(actionExecutedContext.ActionContext.ActionDescriptor.ActionName)
            ?.ReturnParameter
            ?.GetCustomAttribute<TupleElementNamesAttribute>()
            ?.TransformNames;

        var formatter = new JsonMediaTypeFormatter
        {
            SerializerSettings =
            {
                ContractResolver = new ValueTuplesContractResolver(names),
            },
        };

        actionExecutedContext.Response.Content = new ObjectContent(content.ObjectType, content.Value, formatter);
    }
}

ContractResolver:

public class ValueTuplesContractResolver : CamelCasePropertyNamesContractResolver
{
    private IList<string> _names;

    public ValueTuplesContractResolver(IList<string> names)
    {
        _names = names;
    }

    protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
    {
        var properties = base.CreateProperties(type, memberSerialization);
        if (type.Name.Contains(nameof(ValueTuple)))
        {
            for (var i = 0; i < properties.Count; i++)
            {
                properties[i].PropertyName = _names[i];
            }

            _names = _names.Skip(properties.Count).ToList();
        }

        return properties;
    }
}

Usage:

[ReturnValueTuple]
[HttpGet]
[Route("types")]
public IEnumerable<(int id, string name)> GetDocumentTypes()
{
    return ServiceContainer.Db
        .DocumentTypes
        .AsEnumerable()
        .Select(dt => (dt.Id, dt.Name));
}

This one returns next JSON:

[  
   {  
      "id":0,
      "name":"Other"
   },
   {  
      "id":1,
      "name":"Shipping Document"
   }
]

Here the solution for Swagger UI:

public class SwaggerValueTupleFilter : IOperationFilter
{
    public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
    {
        var action = apiDescription.ActionDescriptor;
        var controller = action.ControllerDescriptor.ControllerType;
        var method = controller.GetMethod(action.ActionName);
        var names = method?.ReturnParameter?.GetCustomAttribute<TupleElementNamesAttribute>()?.TransformNames;
        if (names == null)
        {
            return;
        }

        var responseType = apiDescription.ResponseDescription.DeclaredType;
        FieldInfo[] tupleFields;
        var props = new Dictionary<string, string>();
        var isEnumer = responseType.GetInterface(nameof(IEnumerable)) != null;
        if (isEnumer)
        {
            tupleFields = responseType
                .GetGenericArguments()[0]
                .GetFields();
        }
        else
        {
            tupleFields = responseType.GetFields();
        }

        for (var i = 0; i < tupleFields.Length; i++)
        {
            props.Add(names[i], tupleFields[i].FieldType.GetFriendlyName());
        }

        object result;
        if (isEnumer)
        {
            result = new List<Dictionary<string, string>>
            {
                props,
            };
        }
        else
        {
            result = props;
        }

        operation.responses.Clear();
        operation.responses.Add("200", new Response
        {
            description = "OK",
            schema = new Schema
            {
                example = result,
            },
        });
    }
0
watbywbarif On

Problem with using named tuples in your case is that they are just syntactic sugar.

If you check named-and-unnamed-tuples documentation you will find part:

These synonyms are handled by the compiler and the language so that you can use named tuples effectively. IDEs and editors can read these semantic names using the Roslyn APIs. You can reference the elements of a named tuple by those semantic names anywhere in the same assembly. The compiler replaces the names you've defined with Item* equivalents when generating the compiled output. The compiled Microsoft Intermediate Language (MSIL) does not include the names you've given these elements.

So you have problem as you do your serialization during runtime, not during compilation and you would like to use the information which was lost during compilation. One could design custom serializer which gets initialized with some code before compilation to remember named tuple names but I guess such complication is too much for this example.