Generate a client-side proxy for a webservice using HTTP Handlers, Mootools and JSON
.
We will create code that will generate all the javascript necessary to call a webservice, sending and recieving Json. This will allow us to choose wich javascript library (such as Mootools, prototype, scriptaculous, etc...) to use and still be able to performe this task.
Introduction
With the release of the MSFT Ajax Extensions, calling a webservice from client-side is a kids task.
But what if you, like me, want to call a webservice but don't want to use the Ajax Extensions, using instead another library, like mootools? Well you could *just* create the soap body and send it to the webservice. That's seems easy, right?
Well, I like things that generate themselves.
In this post I will create a simple client-side proxy from a webservice, and if all ends well, we will be able to call it and get a response.
Background info
For understanding how this should be done, I went and "reflected" the MSFT Ajax Extensions assemblies to see how did they get this to work. So some of the code presented in this proof of concept is based on this. Again, the main ideia is to understand how to build a proxy similar to the used by the MSFT Ajax Extensions but without really using it.
"Why don't you use the MSFT Ajax Extensions?"
Well, first of all I wanted to learn how the whole process worked.
I also wanted to be able to call a webservice by sending and receiving Json without using the MSFT Ajax Extensions. Many small sized libraries make XHR calls. Why not used them.
Another issue, not covered here, is the usage of this code (with some slight changes) on the v1.1 of the .NET Framework.
The first thing...
... that we need to do is understand the life cycle of this:
Given a webservice (or a list of webservices), the application will validate if the webservice has the [AjaxRemoteProxy] attribute. If so, we will grab all the [WebMethod] methods that are public and generate the client-side proxy. When the client-proxy is called, on the server we need to get the correct method, invoke him, and return its results "json style". All of this server-side is done with some IHttpHandlers.
A HandlerFactory will do the work on finding out what is needed: The default webservice handler, a proxy handler, or a response handler.
The proxy file will be the asmx itself, but now we will add a "/js" to the end of the call, resulting in something like this:
<script src="http://www.codeproject.com/ClientProxyCP/teste.asmx/js" type="text/javascript"></script>
When the call is made to this, a handler will now that a javascript is needed, and generate it.
"Show me some code"
The first thing we need to have is the AjaxRemoteProxy
attribute. This attribute will allow us to both mark wich webservices and web methods we will be able to call on client-side:
Collapse using System;
namespace CreativeMinds.Web.Proxy
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public class AjaxRemoteProxyAttribute : Attribute
{
#region Private Declarations
private bool _ignore = false;
#endregion Private Declarations #region Properties
public bool Ignore
{
get { return _ignore; }
set { _ignore = value; }
}
#endregion Properties #region Constructor
public AjaxRemoteProxyAttribute()
{
}
public AjaxRemoteProxyAttribute(bool _ignore)
{
this._ignore = _ignore;
}
#endregion Constructor
}
}
Now that we have our attribute, lets create a simple Webservice:
using System.Web.Services;
using CreativeMinds.Web.Proxy;
namespace CreativeMinds.Web.Services{
[AjaxRemoteProxy()]
[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
public class MyWebService : WebService
{
[WebMethod]
public string HelloWorld()
{
return "Hello World";
}
[WebMethod]
public string HelloYou(string name)
{
return "Hello " + name;
}
}
}
Notice that the webservice class is marked with our newly created attribute.
Now comes the cool code. The first thing we now need to do is to let the application know that the calls to *.asmx are now handled by us. So we need to do two things: First create the Handler and then change the web.config file.
The WebServices Handler FactoryAs it was said before, the all *.asmx calls will be handled by us. Because we also want to maintain the normal functionality of the webservices, we need to create a handler factory. This factory will managed the return of the specific handler based on the following assumptions:
- If the context.Request.PathInfo ends with "/js", we need to generate the proxy;
- If the context.Request.ContentType is "application/json;" or we have a context.Request.Headers["x-request"] with "JSON" value, we need to execute a method and return its value;
- otherwise, we let the webservice run normally.
So lets build our factory:
Collapse using System;
using System.Web;
using System.Web.Services.Protocols;
namespace CreativeMinds.Web.Proxy
{
public class RestFactoryHandler:IHttpHandlerFactory
{
#region IHttpHandlerFactory Members
public IHttpHandler GetHandler(HttpContext context, string requestType, string url, string pathTranslated)
{
if (string.Equals(context.Request.PathInfo, "/js", StringComparison.OrdinalIgnoreCase))
{
return new RestClientProxyHandler();
}
else
{
if (context.Request.ContentType.StartsWith("application/json;", StringComparison.OrdinalIgnoreCase) ||
(context.Request.Headers["x-request"] != null &&
context.Request.Headers["x-request"].Equals("json", StringComparison.OrdinalIgnoreCase)))
{
return new RestClientResponseHandler();
}
}
return new WebServiceHandlerFactory().GetHandler(context, requestType, url, pathTranslated);
}
public void ReleaseHandler(IHttpHandler handler)
{
}
#endregion
}
}
Then we also need to let the application know about our factory:
<httpHandlers>
<remove verb="*" path="*.asmx"/>
<add verb="*" path="*.asmx" validate="false" type="CreativeMinds.Web.Proxy.RestFactoryHandler"/>
</httpHandlers>
The client-side proxy generator handlerWhen the context.Request.PathInfo equals "/js", we need to generate the client-side proxy. For this task the factory will return the RestClientProxyHandler
.
Collapse using System.Web;
namespace CreativeMinds.Web.Proxy
{
class RestClientProxyHandler : IHttpHandler
{
private bool isReusable = true;
#region IHttpHandler Members
public void ProcessRequest(HttpContext context)
{
WebServiceData wsd = context.Cache["WS_DATA:" + context.Request.FilePath] as WebServiceData;
if (wsd != null)
{
wsd.Render(context);
}
}
public bool IsReusable
{
get { return isReusable; }
}
#endregion
}
}
Notice two things:
- the handler uses a
WebServiceData
object. This object contains the information about the webservice. So what we do where is get the WebServiceData object from the context.Cache and render it.
- the
context.Cache["WS_DATA:" + ... ]
holds all the WebServiceData
on all webservices that are proxified. This collection is filled also on the WebServiceData
object.
WebServiceData object
As said, the WebServiceData contains basic information about the webservice. It is also responsible for the render and execution of the webservice.
Collapse using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Security;
using System.Text;
using System.Web;
using System.Web.Compilation;
using System.Web.Hosting;
using System.Web.Services;
using System.Web.UI;
using Newtonsoft.Json;
namespace CreativeMinds.Web.Proxy
{
internal class WebServiceData
{
#region Private Declarations
private List<MethodInfo> _methods;
private Type _type;
private string _wsPath;
private object _typeInstance;
#endregion Private Declarations #region Constructor
public WebServiceData(string wsPath)
{
_wsPath = wsPath;
_methods = new List<MethodInfo>();
Process();
}
#endregion Constructor #region Process
private void Process()
{
if (HostingEnvironment.VirtualPathProvider.FileExists(_wsPath))
{
Type type1 = null;
try
{
type1 = BuildManager.GetCompiledType(_wsPath);
if (type1 == null)
{
type1 = BuildManager.CreateInstanceFromVirtualPath(_wsPath, typeof (Page)).GetType();
}
if (type1 != null)
{
object[] objArray1 = type1.GetCustomAttributes(typeof (AjaxRemoteProxyAttribute), true);
if (objArray1.Length == 0)
{
throw new InvalidOperationException("No AjaxRemoteProxyAttribute found on webservice");
}
BindingFlags flags1 = BindingFlags.Public | BindingFlags.DeclaredOnly | BindingFlags.Instance;
MethodInfo[] infoArray1 = type1.GetMethods(flags1);
foreach (MethodInfo info1 in infoArray1)
{
object[] metArray1 = info1.GetCustomAttributes(typeof (WebMethodAttribute), true);
if (metArray1.Length != 0)
{
_methods.Add(info1);
}
}
_type = type1;
if (HttpContext.Current.Cache["WS_DATA:" + VirtualPathUtility.ToAbsolute(_wsPath)] == null)
{
HttpContext.Current.Cache["WS_DATA:" + VirtualPathUtility.ToAbsolute(_wsPath)] = this;
}
}
else
{
throw new ApplicationException("Couldn't proxify webservice!!!!");
}
}
catch (SecurityException)
{
}
}
}
#endregion #region Render
public void Render(HttpContext context)
{
context.Response.ContentType = "application/x-javascript";
StringBuilder aux = new StringBuilder();
if (_type == null) return;
aux.AppendLine(string.Format("RegisterNamespace(\"{0}\");", _type.Namespace));
string nsClass = string.Format("{0}.{1}", _type.Namespace, _type.Name);
aux.AppendLine(string.Format("{0} = function(){{}};", nsClass));
_methods.ForEach(delegate (MethodInfo method)
{
aux.AppendFormat("{0}.{1} = function(", nsClass, method.Name);
StringBuilder argumentsObject = new StringBuilder();
foreach (ParameterInfo info2 in method.GetParameters())
{
aux.AppendFormat("{0}, ", info2.Name);
argumentsObject.AppendFormat("\"{0}\":{0}, ", info2.Name);
}
if (argumentsObject.Length > 0)
{
argumentsObject = argumentsObject.Remove(argumentsObject.Length - 2, 2);
argumentsObject.Insert(0, "{").Append("}");
}
aux.Append("onCompleteHandler){\n");
aux.AppendLine(string.Format("new Json.Remote(\"{1}\", {{onComplete: onCompleteHandler, method:'post'}}).send({0});", argumentsObject.ToString(), VirtualPathUtility.ToAbsolute(_wsPath + "/" + method.Name)));
aux.Append("}\n");
});
context.Response.Write(aux.ToString());
}
#endregion #region Invoke
public void Invoke(HttpContext context)
{
string methodName = context.Request.PathInfo.Substring(1);
if (_typeInstance == null)
_typeInstance = Activator.CreateInstance(_type);
string requestBody = new StreamReader(context.Request.InputStream).ReadToEnd();
string[] param = requestBody.Split('=');
object a = JavaScriptConvert.DeserializeObject(param[1]);
Dictionary<string, object> dic = a as Dictionary<string, object>;
int paramCount = 0;
if (dic != null)
{
paramCount = dic.Count;
}
object[] parms = new object[paramCount];
if (dic != null)
{
int count = 0;
foreach (KeyValuePair<string, object> kvp in dic)
{
Debug.WriteLine(string.Format("Key = {0}, Value = {1}", kvp.Key, kvp.Value));
parms[count] = kvp.Value;
count++;
}
}
MethodInfo minfo = _type.GetMethod(methodName);
object resp = minfo.Invoke(_typeInstance, parms);
string JSONResp = JavaScriptConvert.SerializeObject(new JsonResponse(resp));
context.Response.ContentType = "application/json";
context.Response.AddHeader("X-JSON", JSONResp);
context.Response.Write(JSONResp);
}
#endregion
}
public class JsonResponse
{
private object _result = null;
public object Result
{
get { return _result; }
set { _result = value; }
}
public JsonResponse(object _result)
{
this._result = _result;
}
}
}
When initialized, the WebServiceData object will try to get a Type
from the webservice path. If successful, it will check if the webservice has the AjaxRemoteProxyAttribute
, and if true, will extract the WebMethods list.
The Invoke
method looks at the context.Request.PathInfo
to see what method to execute. It also check if arguments are passed on the context.Request.InputStream
and if so, adds them to the method call. In the end the response is serialized into a Json string and sent back to the client.
The Render method looks at all the WebMethods and creates the client-side code.
The JsonResponse
class is used to simplify the serialization of the Json response.
With this we have completed the first big step: Build the necessary code to generate the proxy.
Now to help up "proxifing" the webservices, we will build a simple helper to use on the webforms:
Collapse using System.Collections.Generic;
using System.Web;
using System.Web.UI;
namespace CreativeMinds.Web.Proxy
{
public static class ProxyBuilder
{
#region Properties
public static List<string> WSProxyList
{
get
{
List<string> aux = HttpContext.Current.Cache["WS_PROXIES_URL"] as List<string>;
HttpContext.Current.Cache["WS_PROXIES_URL"] = aux ?? new List<string>();
return HttpContext.Current.Cache["WS_PROXIES_URL"] as List<string>;
}
set
{
HttpContext.Current.Cache["WS_PROXIES_URL"] = value;
}
}
#endregion Properties
public static void For(string wsPath)
{
if (!WSProxyList.Exists(delegate(string s) { return s == wsPath; }))
{
new WebServiceData(wsPath);
WSProxyList.Add(wsPath);
}
}
public static void RenderAllIn(Page page)
{
WSProxyList.ForEach(delegate(string virtualPath)
{
string FullPath = VirtualPathUtility.ToAbsolute(virtualPath + "/js");
page.ClientScript.RegisterClientScriptInclude(string.Format("WSPROXY:{0}", FullPath), FullPath);
});
}
}
}
The ProxyBuilder.For
method recieves a string with the virtual path to the webservice. With a valid path, this method will add a new WebServiceData
object to the WSProxyList
property.
When no more proxies are needed, the ProxyBuilder.RenderAllIn
should be called. This will register all client script generated by our proxies.
protected void Page_Load(object sender, EventArgs e)
{
ProxyBuilder.For("~/teste.asmx");
ProxyBuilder.RenderAllIn(this);
}
Browsing the page, we can now see the output for our webservice:
RegisterNamespace("CreativeMinds.Web.Services");
CreativeMinds.Web.Services.teste = function(){};
CreativeMinds.Web.Services.teste.HelloWorld = function(onCompleteHandler){
new Json.Remote("/CreativeMindsWebSite/teste.asmx/HelloWorld", {onComplete: onCompleteHandler, method:'post'}).send();
}
CreativeMinds.Web.Services.teste.HelloYou = function(name, onCompleteHandler){
new Json.Remote("/CreativeMindsWebSite/teste.asmx/HelloYou", {onComplete: onCompleteHandler, method:'post'}).send({"name":name});
}
Sweet! The generated javascript resembles our webservice class. We have the namespace CreativeMinds.Web.Services
created, the class name teste
its also there, and its webmethods. Notice that all method calls need a onCompleteHandler
. This will handle all the successfully calls.
Only two step remaining: The Response Handler, and testing it all.
Response HandlerAs you can see in the code generated by the proxy, the call to the webservice method doesn't change:
/CreativeMindsWebSite/teste.asmx/HelloWorld
So how can the know what to return - Json or XML?. Well, we will watch for the
context.Request.ContentType
and the
context.Request.Headers
on our
RestFactoryHandler
class. If one of thoose as Json on it we know what to do... :)
When a Json response is requested, the RestFactoryHandler
will return the RestClientResponseHandler
.
Collapse using System.Web;
namespace CreativeMinds.Web.Proxy
{
public class RestClientResponseHandler : IHttpHandler
{
#region IHttpHandler Members
public void ProcessRequest(HttpContext context)
{
WebServiceData wsd = context.Cache["WS_DATA:" + context.Request.FilePath] as WebServiceData;
if (wsd != null)
{
wsd.Invoke(context);
}
}
public bool IsReusable
{
get { return true; }
}
#endregion
}
}
Again notice that it tries to get a WebServiceData
object from the context.Cache
and Invoke
it passing the context as argument. The Invoke
method of the WebServiceData
will extract the method name form the PathInfo. Then it will create an instance from the Type, check for arguments passed on the post by checking the Request.InputStream
. Using the Newtonsoft JavaScriptDeserializer we deserialize any arguments and add them to the object collection needed to invoke a method. Finally we invoke the method, serialize the response and send it back to the client.
Collapse ...
namespace CreativeMinds.Web.Proxy
{
internal class WebServiceData
{
...
public void Invoke(HttpContext context)
{
string methodName = context.Request.PathInfo.Substring(1);
if (_typeInstance == null)
_typeInstance = Activator.CreateInstance(_type);
string requestBody = new StreamReader(context.Request.InputStream).ReadToEnd();
string[] param = requestBody.Split('=');
object a = JavaScriptConvert.DeserializeObject(param[1]);
Dictionary<string, object> dic = a as Dictionary<string, object>;
int paramCount = 0;
if (dic != null)
{
paramCount = dic.Count;
}
object[] parms = new object[paramCount];
if (dic != null)
{
int count = 0;
foreach (KeyValuePair<string, object> kvp in dic)
{
Debug.WriteLine(string.Format("Key = {0}, Value = {1}", kvp.Key, kvp.Value));
parms[count] = kvp.Value;
count++;
}
}
MethodInfo minfo = _type.GetMethod(methodName);
object resp = minfo.Invoke(_typeInstance, parms);
string JSONResp = JavaScriptConvert.SerializeObject(new JsonResponse(resp));
context.Response.ContentType = "application/json";
context.Response.AddHeader("X-JSON", JSONResp);
context.Response.Write(JSONResp);
}
...
With this is are ready to test a call. So all we need to do is, first create the onCompleteHandler
function to handle the response:
function completedHandler(json)
{
alert(json.Result);
}
Then add a textbox to the page:
<input type="textbox" id="txtName" />
Finally, a caller:
<a href="#" onclick="CreativeMinds.Web.Services.teste.HelloYou($("textbox").value, complete)">call HelloYou</a>
That's it. We have build a proxy generator.
Again this is a proof of concept, so its not tested for performance nor bug/error proof.