This post describes how to perform Url Routing to a Web Service. If you’re just interested in the solution, click here.
OK, this was a tough one.
The goal was to implement URL routing for an existing web site that has asmx Web Services. The intention was to allow white-labeling of the Web Services. You might argue why one would want to do that, so I’ll summarize by writing that one of the reasons to do so was that I wanted to enjoy URL Routing advantages, without having to rewrite the existing Web Services into a more “modern” alternative such as MVC. Other reasons are obvious: Web APIs are becoming more and more URL friendly and allow great flexibility.
Note that the web site is written in .NET 4 over IIS7, so I won’t get into how to configure routing or web services. You can read here about Routing, and specifically about Routing in WebForms here.
Unlike routing in MVC which you can’t really do without, in WebForms this is less trivial. In WebForm’s Routing we have the MapPageRoute method, but we have nothing specific for Web Services or other handlers. I already blogged about how to route to a handler (ashx), but I found it much less trivial to accomplish routing for Web Services, and in this post I’ll try to explain why.
Setup
The Web Service in this example has a simple HelloWorld method that returns a greeting:
using System.Web; using System.Web.Services; [WebService(Namespace = "http://evolpin/")] [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)] [System.Web.Script.Services.ScriptService] public class MyWebService : System.Web.Services.WebService { [WebMethod] public string HelloWorld(string name) { string product = HttpContext.Current.Request.RequestContext.RouteData.Values["product"] as string; return string.Format("Hello {0} from {1}", name, product); } }
- Line 6: very important – allow the Web Service to be invoked from a client script.
- Line 12 I attempt to retrieve the {product} part from the Route data. This is an example for the desired white-labeling.
The Global.asax looks like this:
<%@ Application Language="C#" %> <%@ Import Namespace="System.Web.Routing" %> <script runat="server"> void Application_Start(object sender, EventArgs e) { RegisterRoutes(RouteTable.Routes); } public static void RegisterRoutes(RouteCollection routes) { routes.Add(new System.Web.Routing.Route("{product}/xxx/{*pathInfo}", new WebServiceRouteHandler("~/MyWebService.asmx"))); routes.MapPageRoute("", "{*catchall}", "~/Test.aspx"); } </script>
- Line 11 contains the desired route (e.g. http://localhost/MyProduct/xxx/HelloWorld).
- Line 13 contains a route to the test page.
The web.config looks like this:
<?xml version="1.0"?> <configuration> <system.web> <webServices> <protocols> <add name="HttpGet"/> <add name="HttpPost"/> </protocols> </webServices> <compilation debug="true" targetFramework="4.0" /> </system.web> </configuration>
The test page (Test.aspx) looks like this:
And its code:
<%@ Page Language="C#" %> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <script type="text/javascript" src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.3.min.js"></script> </head> <body> <form id="form1" runat="server"> <script type="text/javascript"> function doGet() { $.ajax({ url: "http://localhost/Routing/" + $('#product').val() + "/xxx/HelloWorld?name=evolpin", type: "GET", success: function (result) { alert(result.firstChild.textContent); } }); } function doPostXml() { $.ajax({ url: "http://localhost/Routing/" + $('#product').val() + "/xxx/HelloWorld", type: "POST", data: { name: 'evolpin' }, success: function (result) { alert(result.firstChild.textContent); } }); } function doPostJson() { $.ajax({ url: "http://localhost/Routing/" + $('#product').val() + "/xxx/HelloWorld", type: "POST", contentType: 'application/json', data: JSON.stringify({ name: 'evolpin' }), success: function (result) { alert(result.d); } }); } </script> <div> <input type="text" id='product' value='MyProduct' /> <input type="button" value='GET' onclick='doGet();' /> <input type="button" value='POST xml' onclick='doPostXml();' /> <input type="button" value='POST json' onclick='doPostJson();' /> </div> </form> </body> </html>
- Lines 11-19 perform a GET with a query string.
- Lines 21-30 perform a POST with regular text/xml content type.
- Lines 32-42 perform a POST using JSON.
Note that all these JavaScript methods take the “product name” from the ‘product’ text box.
And finally, the C# client test code which will invoke the Web Service using SOAP looks like this:
namespace ConsoleApplication1 { class Program { static void Main(string[] args) { using (localhost.MyWebService ws = new localhost.MyWebService()) { ws.Url = "http://localhost/Routing/MyProduct/xxx"; string s = ws.HelloWorld("evolpin"); } } } }
Line 9: Changing the target Url to the Route.
The code
After googling quite a lot (it seems like there’s very little information about this), I saw several posts that used the following solution:
using System.Web; using System.Web.Routing; public class WebServiceRouteHandler : IRouteHandler { private string virtualPath; public WebServiceRouteHandler(string virtualPath) { this.virtualPath = virtualPath; } public IHttpHandler GetHttpHandler(RequestContext requestContext) { return new System.Web.Services.Protocols.WebServiceHandlerFactory().GetHandler(HttpContext.Current, "*", this.virtualPath, HttpContext.Current.Server.MapPath(this.virtualPath)); } }
While this solution works for SOAP, if you try to GET or POST, the route won’t work well and you’ll end up receiving the auto generated WSDL documentation instead of calling your actual method. This occurs because the route that I was using was an extensionless route and for reasons explained below, the factory was unable to establish which protocol to use, so it ended-up with the Documentation handler. The WebServiceHandlerFactory attempts to understand which handler should be used to serve the request, and to do so it queries the different protocols it supports (e.g. SOAP, GET, POST etc.). I’ll use a GET request in order to explain why this doesn’t work well. The GET request used in this example is:
http://localhost/Routing/MyProduct/xxx/HelloWorld?name=evolpin
- ‘xxx’ is the Web Service alternate route name.
- ‘MyProduct’ is the sample product using the white-label feature.
- ‘HelloWorld’ is the web method name that I would like to invoke.
- ‘name’ is the name of the argument.
The above image shows how the factory iterates over the available server protocols in an attempt to locate one suitable to serve the request. Here are the different supported protocols:
If we peak into one of the supported protocols, GET, we can see that it queries the PathInfo. If the PathInfo is too short, a null is returned and the factory will continue to search for a suitable protocol.
The PathInfo in this sample GET request is empty (“”). This is because I’m using an extensionless route. To make a long story short, after drilling down into the internals, the PathInfo in IIS7 is built on the understanding what the actual path info is (e.g. /HelloWorld). Because of the extensionless URL, the IIS7 fails to determine what the PathInfo is and sets it to empty, as seen below:
Now that I realized that there’s a problem with extensionless URLs, as IIS can’t parse the PathInfo correctly, I tried changing the route by adding ‘.asmx’ to it, hoping that this will help to resolve the correct path info. So I changed Global.asax and Test.aspx to use the .asmx, and the new route looks like this:
http://localhost/Routing/MyProduct/xxx.asmx/HelloWorld
When I tried GET or POST (using xml for the response content type), the new route worked! IIS7WorkerRequest was able to parse the PathInfo and the GET/POST protocol classes “were content” and used successfully. However, when I tried to use JSON I got back the following error:
System.InvalidOperationException: Request format is invalid: application/json; charset=UTF-8.
at System.Web.Services.Protocols.HttpServerProtocol.ReadParameters()
at System.Web.Services.Protocols.WebServiceHandler.CoreProcessRequest()
Looking at HttpServerProtocol.ReadParameters, I could see that there’s an iteration which attempts to determine which reader class should be used, according not only to the request type (e.g. POST), but also according to the content type:
So I placed a breakpoint in the Route handler and drilled down to see what readerTypes exist and found out that there were two: UrlParameterReader and HtmlFormParameterReader:
As the debugger showed that hasInputPayload was ‘true’, I realized that the HtmlFormParameterReader was the one used. Looking at the code there, it became clear why using JSON failed:
I realized that unless I am missing something, WebServiceHandlerFactory was simply not good enough because while it did solve the GET/POST and xml content-type routing issue, it wasn’t able to handle the JSON content, which I wasn’t willing to give up on.
I tried looking for a different approach, placed a breakpoint in the HelloWorld method and invoked it “old fashion way”, without any routing or JSON. This is what I saw in the debugger when I was searching for which handler was used:
What’s this? It seems like the built-in ASP.NET handlers are not using the WebServiceHandlerFactory directly, but a certain ScriptHandlerFactory. OK, so let’s review the debugger when a JSON request is sent:
It seems like for JSON, the “built-in mechanism” is not using the WebServiceHandlerFactory at all, but a RestHandlerFactory, wrapped by the ScriptHandlerFactory. I have been using the wrong factory all along! Here’s the GetHandler of ScriptHandlerFactory:
As you can see, the ScriptHandlerFactory is a wrapper that instantiates other handlers and wrappers according to the request method and content types. Using the ScriptHandlerFactory seems like the correct option in order to have routing invoke the web service correctly, with different request methods and content types.
Solution:
Unfortunately, for some reason ScriptHandlerFactory is internal and non-public. So I had to use Reflection to invoke it. However, this didn’t work well because I kept receiving wrong handler classes (especially when I removed the .asmx added before to the route). After doing some more Reflection research, and remembering that the logic for retrieving the handlers and protocols was very dependent on the PathInfo, I was looking for a way to change it. PathInfo is actually a getter-only property which is dependent how IIS resolves the path. Fortunately, it seems like the path can be changed by calling the HttpContext.RewritePath method. So the resulting code looks like this:
using System; using System.Web; using System.Web.Routing; public class WebServiceRouteHandler : IRouteHandler { private static IHttpHandlerFactory ScriptHandlerFactory; static WebServiceRouteHandler() { var assembly = typeof(System.Web.Script.Services.ScriptMethodAttribute).Assembly; var type = assembly.GetType("System.Web.Script.Services.ScriptHandlerFactory"); ScriptHandlerFactory = (IHttpHandlerFactory)Activator.CreateInstance(type, true); } private string virtualPath; public WebServiceRouteHandler(string virtualPath) { this.virtualPath = virtualPath; } public IHttpHandler GetHttpHandler(RequestContext requestContext) { string pathInfo = requestContext.RouteData.Values["pathInfo"] as string; if (!string.IsNullOrWhiteSpace(pathInfo)) pathInfo = string.Format("/{0}", pathInfo); requestContext.HttpContext.RewritePath(this.virtualPath, pathInfo, requestContext.HttpContext.Request.QueryString.ToString()); var handler = ScriptHandlerFactory.GetHandler(HttpContext.Current, requestContext.HttpContext.Request.HttpMethod, this.virtualPath, requestContext.HttpContext.Server.MapPath(this.virtualPath)); return handler; } }
- Lines 6-12 create a static ScriptHandlerFactory (no need to recreate it every time that the RouteHandler is used.
- Lines 14-18 is a constructor that receives the virtual path for which the RouteHandler is instantiated for.
- Lines 22-24: determine the pathInfo we want.
- Line 26 modifies the PathInfo. This is the magic!
- Line 27 invokes the logic of the ScriptHandlerFactory, which returns the appropriate handler according to the requset method and content type.
And the result (GET/POST, xml/JSON):
SOAP (this was tested working with Soap 1.1 and 1.2):
Summary
I would be more than happy if someone has a better solution to this. The described solution here is still not complete. Besides the used Reflection, what’s missing is that the Documentation protocol (i.e. WSDL auto generation) should invoke the different methods using the {product} of the Url Route. If you try to use this solution and activate the HelloWorld method using the WSDL help page, you’ll see that it uses the wrong url for invocation.
What’s good about this solution is that it’s very simple, it allows SOAP, GET, POST and JSON. It also allows you to use Url routing advantages, and without having to use the asmx extension in the route. In other words, complete Url Routing flexibility. I also successfully tested a version of this solution in IIS6.
Credits: I used the excellent ILSpy for Reflection, which is a very good substitute to Red-Gate’s not-free-any-more Reflector.