ApiController: configuration distincte par contrôleur

Objectif

Migrer un service web existant vers WebAPI (contrainte sur le format des réponses pour rester compatible avec les clients existants).

Concepts

  • ApiController
  • MediaTypeFormatter
  • IControllerConfiguration

Ma première approche a été de ne pas compliquer le nouveau développement avec un filtre particulier, et de me limiter à retourner un seul format de réponse directement depuis le contrôleur de l’API. Cette solution est certainement faisable mais elle est en fait plus compliquée et ne va pas dans le sens suggéré par les WebAPI d’ASP.NET MVC.

La solution la plus élégante est d’appliquer un MediaTypeFormatter au contrôleur. Je pense que c’est également la méthode la plus simple.

Dans l’exemple ci-dessous, on souhaite migrer un ancien service web qui génère le résultat XML suivant:

<?xml version="1.0" encoding="utf-16"?>
<news id="b30d75b4-cab0-4294-8589-03f56c954e71">
  <items id="99" index="false">
    <cat>IT</cat>
    <title><![CDATA[My article]]></title>
    <desc><![CDATA[Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac justo justo, id vehicula magna. Fusce sed tortor in magna suscipit sodales. Donec ut augue convallis ligula aliquet dignissim. Nunc quam mauris, feugiat vel rutrum quis, luctus sed sem. Cras et tellus quis arcu cursus egestas. Nam eleifend blandit dapibus. Maecenas vehicula porttitor sollicitudin.]]></desc>
    <revision>1</revision>
    <tags>
      <tag>IT</tag>
      <tag>computer</tag>
    </tags>
  </items>
  <items id="100" index="false">
    <cat>IT</cat>
    <title><![CDATA[My article #2]]></title>
    <desc><![CDATA[Nulla ac mauris nec orci tempus pulvinar. Suspendisse massa leo, consequat eget venenatis vel, porttitor non nunc. Cras lobortis porta leo. Morbi eu felis magna. Nulla orci metus, pretium a interdum quis, facilisis in augue. Maecenas imperdiet volutpat neque, vitae accumsan sapien placerat ac. Etiam a nunc nec est accumsan dignissim. Suspendisse fringilla pellentesque erat, vel tempor tellus eleifend non. Donec lobortis porttitor ipsum at elementum. Phasellus sed nisi ut risus lacinia mollis viverra in ipsum. Nullam eget tortor ut ligula imperdiet pellentesque. In hac habitasse platea dictumst. Praesent a leo sed sem porta euismod sit amet vitae justo.]]></desc>
    <revision>1</revision>
    <tags>
      <tag>IT</tag>
      <tag>computer</tag>
    </tags>
  </items>
</news>

Noter que ce résultat est généré à partir de la classe XmlSerializer et de l’attribut Serializable. Pour plus de lisibilité, les classes entités utilisées dans cet exemple sont décrites à la fin de l’article (rien d’intéressant).

On souhaite donc retourner un résultat identique. Le moyen le plus simple est d’implémenter un MediaTypeFormatter qui utilise lui aussi XmlSerializer, avec les mêmes classes entités.

L’essentiel du service existant est réutilisable : les classes entités et la logique métier pour obtenir ces entités. Il y a en fait relativement peu à développer. Toute l’idée est de s’assurer que ce MediaTypeFormatter sera utilisé quelque soit la façon dont le client interroge le service (toujours retourner la représentation XML et non JSON).

Voici ce MediaTypeFormatter :

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Serialization;

public sealed class SimpleXmlSerializerMediaTypeFormatter : MediaTypeFormatter
{
    private ConcurrentDictionary<Type, XmlSerializer> _serializerCache = new ConcurrentDictionary<Type, XmlSerializer>();
    private static readonly XmlSerializerNamespaces _xmlNamespaces = new XmlSerializerNamespaces();
    private const string MIME_TYPE_APPLICATION_XML = "application/xml";
    private const string MIME_TYPE_TEXT_XML = "text/xml";

    public SimpleXmlSerializerMediaTypeFormatter()
    {
        base.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml"));
        base.SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml"));
        bool flag = false;
        bool throwOnInvalidBytes = true;
        base.SupportedEncodings.Add(new UTF8Encoding(flag, throwOnInvalidBytes));
        bool bigEndian = false;
        bool byteOrderMark = true;
        base.SupportedEncodings.Add(new UnicodeEncoding(bigEndian, byteOrderMark, throwOnInvalidBytes));
        _xmlNamespaces.Add(string.Empty, string.Empty);
    }

    public override bool CanReadType(Type type)
    {
        return false;
    }

    public override bool CanWriteType(Type type)
    {
        if (type == null)
        {
            throw new ArgumentNullException("type");
        }
        return (this._serializerCache.GetOrAdd(type, t => this.CreateDefaultSerializer(t, false)) != null);
    }

    private XmlSerializer CreateDefaultSerializer(Type type, bool throwOnError)
    {
        Exception innerException = null;
        XmlSerializer serializer = null;
        try
        {
            serializer = new XmlSerializer(type);
        }
        catch (InvalidOperationException exception2)
        {
            innerException = exception2;
        }
        catch (NotSupportedException exception3)
        {
            innerException = exception3;
        }
        if ((innerException != null) && throwOnError)
        {
            throw new InvalidOperationException(string.Format("XmlSerializer ne peut pas sérialiser le type {0}. Inspectez l'exception interne pour plus de détails.", type), innerException);
        }
        return serializer;
    }

    private XmlSerializer GetSerializerForType(Type type)
    {
        XmlSerializer orAdd = this._serializerCache.GetOrAdd(type, t => this.CreateDefaultSerializer(t, true));
        if (orAdd == null)
        {
            throw new InvalidOperationException(string.Format("XmlSerializer ne peut sérialiser le type {0}.", type));
        }
        return orAdd;
    }

    public override Task<object> ReadFromStreamAsync(Type type, Stream readStream, HttpContent content, IFormatterLogger formatterLogger)
    {
        throw new NotImplementedException("La désérialisation n'est pas prise en charge par cet objet.");
    }

    private void SerializeObject(object value, Stream stream)
    {
        this.GetSerializerForType(value.GetType()).Serialize(stream, value, _xmlNamespaces);
    }

    public override Task WriteToStreamAsync(Type type, object value, Stream writeStream, HttpContent content, TransportContext transportContext)
    {
        if (type == null)
        {
            throw new ArgumentNullException("type");
        }
        if (writeStream == null)
        {
            throw new ArgumentNullException("writeStream");
        }
        return Task.Factory.StartNew(delegate {
            this.SerializeObject(value, writeStream);
        });
    }
}

Pour s’assurer que notre contrôleur, et uniquement celui-ci, ne retourne que du XML (et non du JSON) quelque soit la demande du client, nous utilisons l’interface IControllerConfiguration à partir d’un attribut:

using System;
using System.Web.Http.Controllers;

    public sealed class NewsControllerControllerConfigurationAttribute : Attribute, IControllerConfiguration
    {
        public void Initialize(HttpControllerSettings settings, HttpControllerDescriptor descriptor)
        {
            var formatters = settings.Formatters;
            formatters.Remove(formatters.JsonFormatter);
            if (formatters.Remove(formatters.XmlFormatter))
                formatters.Add(new SimpleXmlSerializerMediaTypeFormatter());
        }
    }

Il ne reste plus que le contrôleur lui-même auquel on applique l’attribut défini ci-dessus:

using System;
using System.Web.Http;

    [NewsControllerControllerConfigurationAttribute]
    public class NewsController : ApiController
    {
        // GET api/news
        public Entities.NewsList Get()
        {
            // [...] récupération de l'entité NewsList (réutilisation de l'ancienne implémentation)
            return _newsManager.GetNews();
        }

    }

L’interface IControllerConfiguration est extrêmement intéressante dans ce cas car elle permet d’appliquer un traitement particulier pour un contrôleur spécifique au sein du pipeline WebAPI sans affecter les autres contrôleurs de l’application. Ce contrôleur peut également être externalisé dans une Portable Areas de MvcContrib. Au moment du déploiement sur un site hôte générique, la configuration spécifique de ce contrôleur ne risque pas de créer de conflit avec les autres services du site.

Noter la condition dans NewsControllerControllerConfigurationAttribute : la configuration est une copie de la configuration globale. Cette copie est mise en cache pour toutes les instances de ce contrôleur. Cette configuration n’a donc pas besoin d’être recréée à chaque appel.

Autres références…

Mike Stall propose un article détaillé sur son blog a propos de l’interface IControllerConfiguration.

Définition des classes entités

using System;
using System.Xml.Serialization;

namespace Entities
{
    [XmlRoot("news")]
    [Serializable]
    public sealed class NewsList
    {
        [XmlAttribute("id")]
        public string Id { get; set; }

        [XmlElement("items")]
        public NewsAbstract[] News { get; set; }
    }

    [XmlRoot("news")]
    [Serializable]
    public sealed class NewsAbstract
    {
        [XmlAttribute("id")]
        public int Id;

        [XmlAttribute("index")]
        public bool Indexe { get; set; }

        [XmlElement("cat")]
        public string Category
        {
            get;
            set;
        }

        [XmlElement("title")]
        public CDataValue Title
        {
            get;
            set;
        }

        [XmlElement("desc")]
        public CDataValue Description
        {
            get;
            set;
        }

        [XmlElement("revision")]
        public int Revision { get; set; }

        [XmlArray("tags")]
        [XmlArrayItem("tag")]
        public string[] Tags
        {
            get;
            set;
        }
    }

    [Serializable]
    public sealed class CDataValue : IXmlSerializable
    {
        public string Value { get; set; }

        #region ctor
        public CDataValue()
        {

        }

        public CDataValue(string elementValue)
        {
            Value = elementValue;
        } 
        #endregion

        #region IXmlSerializable
        public XmlSchema GetSchema()
        {
            return null;
        }

        public void WriteXml(XmlWriter w)
        {

            if (Value != null)
                w.WriteCData(Value);
        }

        public void ReadXml(XmlReader r)
        {
            throw new NotImplementedException("This method has not been implemented");
        } 
        #endregion

        public static explicit operator CDataValue(string b)
        {
            return new CDataValue(b);
        }

        public static explicit operator String(CDataValue b)
        {
            return b.Value;
        }

        public override string ToString()
        {
            return Value;
        }
    }
}