The SOAP XML generated by WCF can be customized but end-to-end documentation describing how to do it is becoming difficult to find. This article puts together steps from several different sources -most written more than 10 years ago- to explain how to inject a custom XmlSerializer into a WCF Operation in order to customize the response format.
The example code that accompagnies this article can be found in my TimeZoneConverter repository on Github. It hosts a version using .NET 4.8 and a version using .NET 6.0 with the CoreWCF libraries.
Starting from the WSDL File
Our team was tasked to build a new SOAP Service to run as a drop-in replacement for an existing one. We were given a .wsdl
file and a folder full of .xsd
files to start from.
As we all are C# developers we decided to stick to the available Microsoft tooling. We created a Visual Studio C# project on the .NET Framework 4.8 and generated the ServiceContract
using SvcUtil.exe
in following Powershell script:
$svcUtilExe = "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\SvcUtil.exe"
& $svcUtilExe "./../../Contracts/TimeZoneConverter.wsdl" "./../../Contracts/TimeZoneConverter.xsd" /t:code /language:"C#" /ser:XmlSerializer /syncOnly /o:ITimeZoneConverter.cs /n:"*,TimeZoneConverter.Contracts" /d:"./Contracts" /config:"service.config"
Custom XmlSerializer
We needed to introduce a custom date/time format but changing the classes generated by svcutil.exe
is highly discouraged because the changes will be overwritten when regenerating the files. These classes however are generated as partial classes, so it is possible to inject custom properties and tell the XmlSerializer to use those.
The ToDateTime
field in the XSD was defined as a datetime with the toDateTimeField
backing field, that can represent either UTC or local time. But this was not sufficient as we needed to be able to respond with any timezone offset. WCF’s default Xml Serializer cannot do this, so we needed to inject a custom one.
This is the TimeZoneConversionResponse
as generated by svcutil.exe
:
[System.CodeDom.Compiler.GeneratedCodeAttribute("svcutil", "4.8.3928.0")]
[System.SerializableAttribute()]
[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.ComponentModel.DesignerCategoryAttribute("code")]
[System.Xml.Serialization.XmlTypeAttribute(Namespace="http://bartjolling.github.io/")]
public partial class TimeZoneConversionResponse
{
private System.DateTime toDateTimeField;
/// <remarks/>
[System.Xml.Serialization.XmlElementAttribute(Order=0)]
public System.DateTime ToDateTime
{
get
{
return this.toDateTimeField;
}
set
{
this.toDateTimeField = value;
}
}
}
By creating below partial class, we added an additional ToDateTimeOffset
string property with the ToDateTimeOffsetField
as backing field. It works as follows:
- When creating the response, use the setter of
ToDateTimeOffset
to validate and parse the value into its backing field. Also set the originaltoDateTimeField
to the corresponding UTC value. - When serializing the response, the Xml Serializer reads the getter of
ToDateTimeOffset
. The value in the backing field will be formatted with the specified offset.
public partial class TimeZoneConversionResponse
{
private DateTimeOffset ToDateTimeOffsetField;
[XmlElement(Order = 1)]
public string ToDateTimeOffset
{
get
{
return ToDateTimeOffsetField.ToString("o", CultureInfo.InvariantCulture);
}
set
{
ToDateTimeOffsetField = DateTimeOffset.ParseExact(value, "o", CultureInfo.InvariantCulture);
toDateTimeField = ToDateTimeOffsetField.UtcDateTime;
}
}
}
We then configured the XmlSerializer to replace the old property with the new one during deserialization:
public static XmlAttributeOverrides SerializerOverrides()
{
// Ignore ToDateTime during serialization
var toDateTimeAttributes = new XmlAttributes() { XmlIgnore = true };
// Use ToDateTimeOffset during serialization
var toDateTimeOffsetAttributes = new XmlAttributes();
toDateTimeOffsetAttributes.XmlElements.Add(new XmlElementAttribute()
{
ElementName = "ToDateTime",
Order = 0
});
var attributeOverrides = new XmlAttributeOverrides();
attributeOverrides.Add(typeof(TimeZoneConversionResponse), "ToDateTime", toDateTimeAttributes);
attributeOverrides.Add(typeof(TimeZoneConversionResponse), "ToDateTimeOffset", toDateTimeOffsetAttributes);
return attributeOverrides;
}
Note how the OrderAttribute
of ToDateTimeOffset
was set to 1 (instead of 0) and was only set back to 0 at the same time as replacing the ToDateTime
field in SerializerOverrides
. This way, the modified TimeZoneConversionResponse
could still be read by a vanilla XmlSerializer if desired, albeit without benefitting from the custom field and its formatting.
Creating the XmlObjectSerializer
Unfortunately, it is not possible to pass the XmlAttributesOverrides
to the XmlSerializer that is used by the WCF pipeline. However, we can wrap it in a custom XmlObjectSerializer
and tell WCF to use that one. Andrew Arnott describes how to do this on Microsoft’s DevBlog.
XmlObjectSerializer
requires implementing some methods. Those just forward to methods on the wrapped XmlSerializer
instance. The important one here is WriteObject
. For a full implementation, check out my TimeZoneConverter repository on Github.
public class ConvertToOffsetResponseSerializer : XmlObjectSerializer
{
private readonly XmlSerializer _serializer;
private readonly XmlSerializerNamespaces namespaces;
public ConvertToOffsetResponseSerializer()
{
namespaces = new XmlSerializerNamespaces();
namespaces.Add("xsi", "http://www.w3.org/2001/XMLSchema-instance");
namespaces.Add("xsd", "http://www.w3.org/2001/XMLSchema");
_serializer = new XmlSerializer(typeof(ConvertToOffsetResponse),
SerializerOverrides(), null, null, "http://bartjolling.github.io/");
}
public static XmlAttributeOverrides SerializerOverrides()
{
// ... see code block in the previous paragraph
}
public override object ReadObject(XmlDictionaryReader reader, bool verifyObjectName)
{
if (_serializer.Deserialize(reader) is not ConvertToOffsetResponse deserializedObject)
{
return null;
}
return deserializedObject;
}
public override void WriteObject(XmlDictionaryWriter writer, object graph)
{
if (writer == null)
throw new ArgumentNullException(nameof(writer));
_serializer.Serialize(writer, graph, namespaces);
}
}
Injecting the XmlObjectSerializer in the WCF pipeline
Injecting the custom ConvertToOffsetResponseSerializer
in the pipeline of a WCF Operation requires:
- writing a new DispatchMessageFormatter that defines how requests are deserialized and responses are serialized,
- creating a new OperationBehavior that overwrites the default, built-in formatter for the
XmlSerializerOperationBehavior
, - attaching this OperationBehavior to the WCF Operation using an attribute.
IDispatchMessageFormatter
WCF provides the IDispatchMessageFormatter
interface. This is an extension point that allows us to inject classes that can deserialize request messages and serialize response messages in a WCF service application. It’s at this juncture that we can utilize our custom ConvertToOffsetResponseSerializer class.
On the server side, we only needed to customize the SerializeReply
method in order to incorporate the time offset into the reply. The request handling remained unchanged. Therefore, for the implementation of the DeserializeRequest
method, we leveraged the existing DispatchMessageFormatter
that the class had received through its constructor.
public class ResponseDispatchFormatter : IDispatchMessageFormatter
{
private readonly IDispatchMessageFormatter _requestFormatter;
private readonly XmlObjectSerializer _reponseSerializer;
public ResponseDispatchFormatter(IDispatchMessageFormatter requestFormatter)
{
_requestFormatter = requestFormatter ?? throw new ArgumentNullException(nameof(requestFormatter));
_reponseSerializer = new ConvertToOffsetResponseSerializer();
}
public void DeserializeRequest(Message message, object[] parameters)
{
// Use the default WCF formatter for the request
_requestFormatter.DeserializeRequest(message, parameters);
}
public Message SerializeReply(MessageVersion messageVersion, object[] parameters, object result)
{
// Use the custom serializer to format the response
Message message = Message.CreateMessage(messageVersion, "ConvertToOffset", result, _reponseSerializer);
return message;
}
}
IOperationBehavior
The final step was to attach the ResponseDispatchFormatter
to the WCF Operation that requires the custom response format. This can be done by creating an implementation of the IOperationBehavior
attribute, as shown below.
On server side, we only needed to customize the DispatchBehavior
: we retrieve the inner XmlSerializerOperationBehavior
formatter behavior, apply the DispatchBehavior and set the ResponseDispatchFormatter
we defined above as its formatter.
We passed the original inner formatter to the new DocumentDispatcher because we still use it for deserializing the requests.
public class ResponseSerializerAttribute : Attribute, IOperationBehavior
{
private IOperationBehavior XmlSerializerBehavior { get; set; }
public void AddBindingParameters(OperationDescription operationDescription,
BindingParameterCollection bindingParameters)
{
if (XmlSerializerBehavior != null)
{
XmlSerializerBehavior.AddBindingParameters(operationDescription, bindingParameters);
}
}
public void ApplyClientBehavior(OperationDescription operationDescription,
ClientOperation clientOperation)
{
throw new NotImplementedException();
}
public void ApplyDispatchBehavior(OperationDescription operationDescription,
DispatchOperation dispatchOperation)
{
XmlSerializerBehavior = operationDescription.Behaviors.Find<XmlSerializerOperationBehavior>();
if (XmlSerializerBehavior != null && dispatchOperation.Formatter == null)
{
// no formatter has been applied yet - let WCF initialize the formatter with the default behavior
XmlSerializerBehavior.ApplyDispatchBehavior(operationDescription, dispatchOperation);
}
// Override the default formatter but first pass it to the custom formatter so both can be used in dispatch
dispatchOperation.Formatter = new ResponseDispatchFormatter(dispatchOperation.Formatter);
}
public void Validate(OperationDescription operationDescription)
{
if (XmlSerializerBehavior != null)
{
XmlSerializerBehavior.Validate(operationDescription);
}
}
}
Applying the Attribute
We were finally ready to inject the custom XmlSerializer into the WCF Operation, by simply applying our ResponseSerializerAttribute
to the operation as follows:
public class TimeZoneConverterService : ITimeZoneConverter
{
[ResponseSerializer]
public ConvertToOffsetResponse ConvertToOffset(ConvertToOffsetRequest request)
{
var conversionRequest = request.TimeZoneConversionRequest;
var fromDateTime = new DateTimeOffset(conversionRequest.FromDateTime,
TimeSpan.FromMinutes((double)conversionRequest.FromOffset));
var toDateTime = fromDateTime.ToOffset(TimeSpan.FromMinutes((double)conversionRequest.ToOffset));
var response = new TimeZoneConversionResponse()
{
ToDateTimeOffset = toDateTime.ToString("o", CultureInfo.InvariantCulture)
};
return new ConvertToOffsetResponse(response);
}
}
And there you have it: a WCF operation using a customized XmlSerializer for fine-grained control over the generated reply.