Introduction
With the release of the MSFT AJAX Extensions, calling a Web Service from client-side is a kid's task.
But what if you, like me, want to call a Web Service 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 Web Service. 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 Web Service, 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 they got this to work. So some of the code presented in this proof of concept is based on that. Again, the main idea is to understand how to build a proxy similar to the one used by the MSFT AJAX Extensions, but without really using it.
Why not 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 Web Service by sending and receiving JSON without using the MSFT AJAX Extensions. Many small sized libraries make XHR calls. Why not use 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 Web Service (or a list of Web Services), the application will validate if the Web Service 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 it, and return its results "JSON style". All of this server-side is done with some IHttpHandler
s.
A HandlerFactory will do the work on finding out what is needed: the default Web Service 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 know that 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 mark which Web Services and web methods we will be able to call on the client side:
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, let's create a simple Web Service:
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 Web Service class is marked with our newly created attribute.
Now comes the cool code. The first thing we need to do is 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 Factory
As it was said before, all *.asmx calls will be handled by us. Because we also want to maintain the normal functionality of the Web Services, we need to create a handler factory. This factory will manage the return of the specific handler based on the following assumptions:
- If
context.Request.PathInfo
ends with "/js", we need to generate the proxy; - If
context.Request.ContentType
is "application/json;" or we have a context.Request.Headers["x-request"]
with a "JSON" value, we need to execute a method and return its value; - otherwise, we let the Web Service run normally.
So let's build our factory:
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 handler
When context.Request.PathInfo
equals "/js", we need to generate the client-side proxy. For this task, the factory will return the RestClientProxyHandler
.
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 information about the Web Service. What we do here is get the WebServiceData
object from context.Cache
and render it. context.Cache["WS_DATA:" + ... ]
holds all the WebServiceData
on all Web Services that are proxified. This collection is filled in the WebServiceData
object.
WebServiceData object
As said, WebServiceData
contains basic information about the Web Service. It is also responsible for the rendering and execution of the Web Service.
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 Web Service path. If successful, it will check if the Web Service has AjaxRemoteProxyAttribute
, and if true
, will extract the WebMethods list.
The Invoke
method looks at context.Request.PathInfo
to see what method to execute. It also checks if the 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:
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 receives 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, ProxyBuilder.RenderAllIn
should be called. This will register all the 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 Web Service:
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, and the class name teste
is also there, and its Web Methods. Notice that all method calls need a onCompleteHandler
. This will handle all the calls successfully.
Only two steps remaining: the Response Handler, and testing it all.
Response Handler
As 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 we know what to return - JSON or XML?. Well, we will watch for context.Request.ContentType
and context.Request.Headers
on our RestFactoryHandler
class. If one of those has JSON on it, we know what to do... :)
When a JSON response is requested, RestFactoryHandler
will return RestClientResponseHandler
.
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 context.Cache
and Invoke
it passing the context as the argument. The Invoke
method of WebServiceData
will extract the method name form the PathInfo
. Then, it will create an instance from the Type
, and check for arguments passed on the post by checking Request.InputStream
. Using 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.
...
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, we are ready to test a call. 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 built a proxy generator.
Again, this is a proof of concept, so it's not tested for performance nor is bug/error proof.