Tuesday, March 10, 2009

Using reflection to hack .NET Web Services (Adding backwards compatibility)

ASP.NET Web Services provide a quick and easy way to implement a Web Service but they are not without their shortcomings. The high level nature of ASP.NET Web Services sadly means that some of the low level functionality required to produce a supportable and maintainable Web Service is missing. Because this missing functionality, small changes actually turn out to be breaking changes.

Here a few breaking changes that this post will address:

1. Renaming of the .asmx Web Service - Product names change, one product becomes two and sometimes developers just get bored. Regardless of the reason, changing the .asmx Web Service name/filename will break all existing Web Service consumers.

2. Renaming a specific Web Service method - Typically the easiest solution here is to add a wrapper with the original name that can be phased out after existing Web Service consumers have made the transition. To cover all basis(and because some of you out there might not have the luxury of exposing two methods that have the same functionality) I've included this scenario in this post.

3. Renaming a parameter of a Web Service method - As Web Services as essentially APIs, developers tend to name Web Service method parameters to coincide with their internal parameter names. While this obviously makes the developer's lives a bit easier (consistency is crucial for a developer), consumers of the Web Services may know those parameters by different names (a business web application developer will often use different terminologies for metrics than the business web site developer -- which of course if fine unless the web developer writes a web service intended for the business developer).

4. Adding a new parameter to a Web Service method - .NET Web Services require all parameters to supplied when the method is called. To add a new parameter to a Web Service method without breaking existing deployments requires creating a wrapper with the original to overload the new method with the new parameters. As with renaming a Web Service method, phasing out method wrappers created for compatibility can be a documentation and supportability nightmare. A better alternative (and the #1 feature I wish ASP.NET Web Services had) is to implement default values for Web Service parameters. The overloaded method you create will ultimately pass in a default value anyways, so why not have a formal way of providing that same default to the Web Service method (therefore eliminating the need for the wrapper)?


NOTE: This post is meant purely for educational purposes. Any decisions/code modifications/problems that develop as a result of this post are 100% not my responsibility (it's best to fully understand what the code below is doing before implementing it).


This post will only cover one potential solution to the aforementioned issues. While I won't go into the details of how I determined what fields of the ASP.NET objects needed to be touched, what I will say (as it might provide extremely useful to you) is that if you are using a .NET library (ASP.NET is basically just a large 3rd-party library sitting atop IIS) and are either having issues or simply want to know how something is implemented, give .NET Reflector a try. With .NET Reflector, you can decompile .NET DLLs into a human readable (and compilable) form.



A simple Web Service (a starting point):

The following is a sample HTTP GET request and response.
The placeholders shown need to be replaced with actual values.

GET /SampleWebService/SimpleService.asmx/SimpleMethod?
StrParam=string&IntParam=string&BoolParam=string&EnumParam=string HTTP/1.1

public enum ENUM_ITEMS
{
ITEM1 = 1,
ITEM2 = 2,
ITEM3 = 3
}

[WebMethod]
public string SimpleMethod(String StrParam, int IntParam, bool BoolParam, ENUM_ITEMS EnumParam)
{
return "The Params: "
+ "StrParam=" + StrParam + ", "
+ "IntParam=" + IntParam + ", "
+ "BoolParam=" + BoolParam + ", "
+ "EnumParam=" + EnumParam;
}

A standard request:
http://localhost/SampleWebService/SimpleService.asmx/SimpleMethod?StrParam=TestString&IntParam=9&BoolParam=false&EnumParam=ITEM3
The return value:
    <?xml version="1.0" encoding="utf-8" ?> 
<string xmlns="http://tempuri.org/">The Params: StrParam=TestString, IntParam=9, BoolParam=False, EnumParam=ITEM3</string>


What the follow code will allow:

1. Request to an alternate Web Service (OldWebService.asmx instead of SimpleService.asmx):
http://localhost/SampleWebService/OldWebService.asmx/SimpleMethod?StrParam=TestString&IntParam=9&BoolParam=false&EnumParam=ITEM3

2. Request to an alternate Web Service method (TheOldMethod instead of SimpleMethod):
http://localhost/SampleWebService/SimpleService.asmx/TheOldMethod?StrParam=TestString&IntParam=9&BoolParam=false&EnumParam=ITEM3

3. Request with an alternate parameter name (StringParam instead of StrParam):
http://localhost/SampleWebService/SimpleService.asmx/SimpleMethod?StringParam=TestString&IntParam=9&BoolParam=false&EnumParam=ITEM3

4. Request using the default StrParam (DefaultString) and EnumParam (ITEM2) values:
http://localhost/SampleWebService/SimpleService.asmx/SimpleMethod?IntParam=9&BoolParam=false
The return value:
    <?xml version="1.0" encoding="utf-8" ?> 
<string xmlns="http://tempuri.org/">The Params: StrParam=DefaultString, IntParam=9, BoolParam=False, EnumParam=ITEM2</string>

5. Putting it all together - a request to an alternate Web Service, an alternate Web Service method and with no parameters (using all default parameter values):
http://localhost/SampleWebService/OldWebService.asmx/TheOldMethod
The return value:
    <?xml version="1.0" encoding="utf-8" ?> 
<string xmlns="http://tempuri.org/">The Params: StrParam=DefaultString, IntParam=11, BoolParam=True, EnumParam=ITEM2</string>


The following solution intercepts the ASP.NET request object before ASP.NET reads the request variables to determine what Web Service and Web Service method to call and modifies the request so that ASP.NET will call the Web Service and Web Service method with the parameters of our choosing.


NOTE: Scroll to the bottom of this post to download the entire solution (including the Web Service and preprocessor code).


This snippet is the preprocessor initialization code that will create a Web Service alias, a method alias, parameter aliases and parameter defaults.

private static object m_InitLock = new object();

private static Dictionary<String, String> m_WebServiceMappings = null;
private static Dictionary<String, Dictionary<String, String>> m_MethodMappings = null;
private static Dictionary<String, Dictionary<String, Dictionary<String, String>>> m_ParameterMappings = null;
private static Dictionary<String, Dictionary<String, Dictionary<String, String>>> m_ParameterDefaults = null;

private static void Init()
{
if (m_ParameterMappings != null) return;

lock (m_InitLock)
{
if (m_ParameterMappings != null) return;

m_WebServiceMappings = new Dictionary<String, String>();
m_MethodMappings = new Dictionary<String, Dictionary<String, String>>();
m_ParameterMappings = new Dictionary<String, Dictionary<String, Dictionary<String, String>>>();
m_ParameterDefaults = new Dictionary<String, Dictionary<String, Dictionary<String, String>>>();

String RawWebServiceName, WebServiceName, RawMethodName, MethodName;

// The following will:
// 1. Allow Web Service requests the Web Service 'OldWebService.asmx' to be remapped to 'SimpleService.asmx'
// 2. Allow Web Service requests to the method 'TheOldMetod' to be remapped to method 'SimpleMethod'
// 3. Allow calls to 'SimpleMethod' with the parameter 'StringParameter' to interpret that parameter as 'StrParam'
// 4. Will apply defaults to 'SimpleMethod' parameters not supplied
#region 'SimpleService' mappings/defaults

RawWebServiceName = "SimpleService.asmx";
WebServiceName = RawWebServiceName.ToUpper();

//Allow for 'OldWebService.asmx' calls to be remapped to 'SimpleService.asmx'
m_WebServiceMappings["OldWebService.asmx".ToUpper()] = RawWebServiceName;


RawMethodName = "SimpleMethod";
MethodName = RawMethodName.ToUpper();

//Add alternate method mappings for SimpleMethod
m_MethodMappings[WebServiceName] = new Dictionary<String, String>();
m_MethodMappings[WebServiceName]["TheOldMethod".ToUpper()] = RawMethodName;

//Add alternate parameter mappings for SimpleMethod
m_ParameterMappings[WebServiceName] = new Dictionary<String, Dictionary<String, String>>();
m_ParameterDefaults[WebServiceName] = new Dictionary<String, Dictionary<String, String>>();
m_ParameterMappings[WebServiceName][MethodName] = new Dictionary<String, String>();
m_ParameterDefaults[WebServiceName][MethodName] = new Dictionary<String, String>();

//Allow for an alterate input (StringParameter) for
//the StrParam parameter of SimpleMethod
m_ParameterMappings[WebServiceName][MethodName]["StringParameter"] = "StrParam";

//Init the SimpleMethod defaults
//(cannot override params, only set params that don't already exist)
m_ParameterDefaults[WebServiceName][MethodName]["StrParam"] = "DefaultString";
m_ParameterDefaults[WebServiceName][MethodName]["IntParam"] = "11";
m_ParameterDefaults[WebServiceName][MethodName]["BoolParam"] = "true";
m_ParameterDefaults[WebServiceName][MethodName]["EnumParam"] = "ITEM2";

#endregion
}
}

How the preprocessor is called (via the Global.asax file):

protected void Application_BeginRequest(object sender, EventArgs e)
{
Preprocessor.PreprocessRequest(Server, Request, Response);
}

The preprocessor code (take a deep breath):

public static void PreprocessRequest(HttpServerUtility Server, HttpRequest Request, HttpResponse Response)
{
if (Request == null || Request.Url == null || Request.Url.Segments == null
|| Request.Url.Segments.Length < 3) return;

String RawWebServiceName = Request.Url.Segments[Request.Url.Segments.Length - 2];
if (RawWebServiceName != null && RawWebServiceName.EndsWith("/"))
RawWebServiceName = RawWebServiceName.Substring(0, RawWebServiceName.Length - 1);

if (!RawWebServiceName.EndsWith(".asmx", StringComparison.InvariantCultureIgnoreCase))
return;

String RawMethodName = Request.Url.Segments[Request.Url.Segments.Length - 1];
if (RawMethodName.EndsWith(".asmx", StringComparison.InvariantCultureIgnoreCase))
return;


//Ensure everything has been initialized
Init();

String WebServiceName = RawWebServiceName.ToUpper();
String RawNewWebServiceName = null;
String NewWebServiceName = null;

String MethodName = RawMethodName.ToUpper();
String RawNewMethodName = null;
String NewMethodName = null;

//Determine if the current Web Service (.asmx) call needs to be remapped
if (m_WebServiceMappings.ContainsKey(WebServiceName))
{
RawNewWebServiceName = m_WebServiceMappings[WebServiceName];
NewWebServiceName = RawNewWebServiceName.ToUpper();
}

String WebServiceKey = NewWebServiceName == null ? WebServiceName : NewWebServiceName;

//Determine if the current method call needs to be remapped
if (m_MethodMappings.ContainsKey(WebServiceKey)
&& m_MethodMappings[WebServiceKey].ContainsKey(MethodName))
{
RawNewMethodName = m_MethodMappings[WebServiceKey][MethodName];
NewMethodName = RawNewMethodName.ToUpper();
}

String MethodKey = NewMethodName == null ? MethodName : NewMethodName;

//POST (Form/SOAP) requests need to be handled a bit differently
bool IsPost = Request.RequestType.Trim().ToUpper() == "POST";

//Determine if any new parameter values need to be added
Dictionary<String, String> NewParamValues = GetNewParamValues(Request, WebServiceKey, MethodKey, IsPost);
//if (NewParamValues == null || NewParamValues.Count == 0) return;

//Use reflection to manually insert parameters into the request
RewriteRequest(Request, RawWebServiceName, RawNewWebServiceName,
RawMethodName, RawNewMethodName, NewParamValues, IsPost);
}

private static Dictionary<String, String> GetNewParamValues(HttpRequest Request,
String WebServiceKey, String MethodKey, bool IsPost)
{
Dictionary<String, String> NewParamValues = new Dictionary<String, String>();

//Determine if any parameters need remapping
if (m_ParameterMappings.ContainsKey(WebServiceKey)
&& m_ParameterMappings[WebServiceKey].ContainsKey(MethodKey))
{
String NewParameter;
foreach (String Param in m_ParameterMappings[WebServiceKey][MethodKey].Keys)
{
//Determine if the parameter needs to be remapped (exists)
if (Request.Params[Param] != null)
{
NewParameter = m_ParameterMappings[WebServiceKey][MethodKey][Param];
//Verify that the parameter that it would map to does not already exist
if (NewParameter != null && Request.Params[NewParameter] == null)
NewParamValues[NewParameter] = Request.Params[Param];
}
}
}

//Determine if any parameters need a default value applied
if (m_ParameterDefaults.ContainsKey(WebServiceKey)
& m_ParameterDefaults[WebServiceKey].ContainsKey(MethodKey))
{
foreach (KeyValuePair<String, String> DefaultValue in m_ParameterDefaults[WebServiceKey][MethodKey])
{
if (IsPost)
{
if (Request.Form[DefaultValue.Key] == null)
NewParamValues[DefaultValue.Key] = DefaultValue.Value;
}
else if (Request.Params[DefaultValue.Key] == null)
NewParamValues[DefaultValue.Key] = DefaultValue.Value;
}
}

return NewParamValues;
}

private static void RewriteRequest(HttpRequest Request, String CurrentWebService, String NewWebService,
String CurrentMethod, String NewMethod, Dictionary<String, String> NewParamValues, bool IsPost)
{
try
{
MethodInfo WritableMethod;
MethodInfo ReadOnlyMethod;

BindingFlags Flags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;

//If needed, rewrite a few internal values of HttpWorkerRequest and HttpRequest
//that ASP.NET will use when trying to determine the Web Service/method to call
try
{
bool UpdateWebService = NewWebService != null;
bool UpdateMethod = NewMethod != null && Request.PathInfo.EndsWith(CurrentMethod);

if (UpdateWebService || UpdateMethod)
{
FieldInfo WorkerField = Request.GetType().GetField("_wr", Flags);

HttpWorkerRequest WorkerRequest = (HttpWorkerRequest)WorkerField.GetValue(Request);

FieldInfo FilePathField = null;
FieldInfo PathField = null;
FieldInfo PathInfoField = null;

String filePath = null;
String path = null;
String pathInfo = null;

if (UpdateWebService)
{
FilePathField = WorkerRequest.GetType().GetField("_filePath", Flags);
filePath = (String)FilePathField.GetValue(WorkerRequest);
}

PathField = WorkerRequest.GetType().GetField("_path", Flags);
path = (String)PathField.GetValue(WorkerRequest);

if (UpdateMethod)
{
PathInfoField = WorkerRequest.GetType().GetField("_pathInfo", Flags);
pathInfo = (String)PathInfoField.GetValue(WorkerRequest);
}

if (UpdateWebService)
{
filePath = path.Substring(0, filePath.Length - CurrentWebService.Length);
filePath += NewWebService;

path = path.Replace("/" + CurrentWebService + "/",
"/" + NewWebService + "/");
}

if (UpdateMethod)
{
path = path.Substring(0, path.Length - CurrentMethod.Length);
path += NewMethod;

pathInfo = pathInfo.Substring(0, pathInfo.Length - CurrentMethod.Length);
pathInfo += NewMethod;
}

if (UpdateWebService)
{
FilePathField.SetValue(WorkerRequest, filePath);
FilePathField = Request.GetType().GetField("_filePath", Flags);
FilePathField.SetValue(Request, null);
}

PathField.SetValue(WorkerRequest, path);
PathField = Request.GetType().GetField("_path", Flags);
PathField.SetValue(Request, null);

if (UpdateMethod)
{
PathInfoField.SetValue(WorkerRequest, pathInfo);
PathInfoField = Request.GetType().GetField("_pathInfo", Flags);
PathInfoField.SetValue(Request, null);
}
}
}
catch { }

//Remap or set defaults for Web Service parameters
if (NewParamValues.Count > 0)
{
if (IsPost)
{
WritableMethod = Request.Form.GetType().GetMethod("MakeReadWrite", Flags);
ReadOnlyMethod = Request.Form.GetType().GetMethod("MakeReadOnly", Flags);

FieldInfo FormField = Request.GetType().GetField("_form", Flags);

WritableMethod.Invoke(Request.Form, null);

foreach (String NewParam in NewParamValues.Keys)
Request.Form[NewParam] = NewParamValues[NewParam];

FormField.SetValue(Request, Request.Form);
ReadOnlyMethod.Invoke(Request.Form, null);
}
else
{
WritableMethod = Request.QueryString.GetType().GetMethod("MakeReadWrite", Flags);
ReadOnlyMethod = Request.QueryString.GetType().GetMethod("MakeReadOnly", Flags);

FieldInfo QueryStringField = Request.GetType().GetField("_queryStringText", Flags);
FieldInfo QueryStringOverridenField = Request.GetType().GetField("_queryStringOverriden", Flags);

MethodInfo ResetMethod = Request.QueryString.GetType().GetMethod("Reset", Flags);
MethodInfo FillMethod = Request.GetType().GetMethod("FillInQueryStringCollection", Flags);

String QueryString = Request.QueryString.ToString();
WritableMethod.Invoke(Request.QueryString, null);

QueryString = QueryString.Replace(CurrentMethod, NewMethod);

foreach (String NewParam in NewParamValues.Keys)
Request.QueryString[NewParam] = NewParamValues[NewParam];

QueryStringField.SetValue(Request, Request.QueryString.ToString());
QueryStringOverridenField.SetValue(Request, true);

ResetMethod.Invoke(Request.QueryString, null);
FillMethod.Invoke(Request, null);
ReadOnlyMethod.Invoke(Request.QueryString, null);
}
}
}
catch (Exception) { }
}


Download the source code: SampleWebService.zip


0 comments:

Post a Comment