giovedì 17 gennaio 2008

Generating ASP.NET web pages, without web servers

Today I had to find a way to embed some dynamic web pages in a Windows Forms application - better: I have the usual couple of .aspx and .cs files, and want to get the html output.
Some alternatives come up to mind: Cassini and Mono's XSP can easily be embedded in a standalone exe and provide a fully featured web server. However my requirements were much simpler, I don't need the ability to process requests and give responses - just spit out some HTML to display to the user, like a report. Moreover these approaches require listening to ports, running real web requests... I know that .NET contains all the required magic to process ASP.NET pages, so why can't I call some magic function and get the job done?

Well, it turns out that it's not as simple as calling a function, but with a few lines of code it can be done.

Create a solution with two projects. One is a Class Library project (called HostMarshaller), the other is our main app - for simplicity, let's choose a Console Application. In both projects add a reference to System.Web, and in the Console App add a reference to HostMarshaller.
Open the properties window of the Console App and switch to the Build Events tab. You'll need to add the following post-build command:

copy "$(TargetDir)HostMarshaller.dll" "$(TargetDir)bin\HostMarshaller.dll"

Now let's get to the code.
In HostMarshaller's generated Class1.cs, paste these lines:

using System;
using System.IO;
using System.Web;
using System.Web.Hosting;

public class HostMarshaller: MarshalByRefObject
{
public string Run(string page, string query)
{
StringWriter outputWriter = new StringWriter();
SimpleWorkerRequest request = new SimpleWorkerRequest(page, query, outputWriter);
HttpRuntime.ProcessRequest(request);

return outputWriter.GetStringBuilder().ToString();
}
}

Now open the Program.cs in the Console App. In the main() body, paste these lines:
// For this to work, the HostMarshaller assembly must be located 
// in a \bin\ subfolder of the directory containing this EXE.
// A post-build command takes care of this.

System.Reflection.Assembly a = System.Reflection.Assembly.GetEntryAssembly();
string baseFolder = System.IO.Path.GetDirectoryName(a.Location);

HostMarshaller result = (HostMarshaller)ApplicationHost.CreateApplicationHost(typeof(HostMarshaller), "/", baseFolder);
string output = result.Run("report.aspx", String.Empty);

Console.WriteLine(output);

And add the using statement for System.Web.Hosting.
This code is enough to achieve our purpose. Now we just need to create the report.aspx.
Compile the project and ignore any errors, these are normal.

Open a file manager and go to the bin\Debug\ folder of the Console App project. Here you'll have to create another bin subfolder.
Recompile - this time it will build just fine.

In the debug folder, create a file called "report.aspx" with this content:
<%@ Page Language="C#" %>
<html>
<head></head>
<body>
<asp:Label runat="server" Font-Bold="true">3 + 2 is <%=3 + 2 %></asp:Label>
</body>
</html>

Run the project and enjoy the generated web page :)

What's happening there


As you may have noticed, the bulk of the work is done by the HttpRuntime.ProcessRequest call. However this call requires a specially-setup AppDomain, that our Console App does not have, so we have to create another AppDomain by the use of ApplicationHost.CreateApplicationHost.
An instance of the HostMarshaller class gets created in this new AppDomain, and in its Run method we launch the request processing.
To pass parameters to the web page, you can use the second parameter of the Run method, it is a standard query string in the form key=value&key=value...

That's it, have fun!

giovedì 20 dicembre 2007

ResolveUrl not resolving

Since the .net conversion of SkakkiNostri, we were facing a rare and strange problem.
When the user clicks the quick Search button, it is internally redirected to a more specialized page using the line:

Response.Redirect(ResolveUrl("~/search_users.aspx?quickValue=" + txtUser.Text), true);

This line was causing 404 errors, with the ResolveUrl call apparently not doing what it has to do: the user was redirected to http://www.skakkinostri.it/~/search_users.aspx?quickValue=something.

Can you spot the problem?

Hint: today, while skimming through the error list I noticed something strange. All of these error reports were for users that contained unicode characters. Lots of them, like in the word "•]•·´º´·» .:. rO£@Lb@ ιи ℓσνє.:. [♡] «·´º´·•[•".

Then came the enlightenment: maybe the ResolveUrl call fails if the URL is not perfectly url-formatted!
And it turns out it's just that way. As simple as it seems, the offending call was promptly replaced with:

Response.Redirect(ResolveUrl("~/search_users.aspx?quickValue=" + Server.UrlEncode(txtUser.Text)), true);

and suddenly exceptions stopped.
Now let's go for a check of all the Response.Redirect calls in the web site :)

venerdì 30 novembre 2007

Custom validators and Nokia Browser

On SkakkiNostri, we have a custom RequiredFieldValidator for a custom control that basically boils down to a textarea. We had a bug report stating that people using the Nokia S60 built-in browser always failed the required field validation. Well, it turns out that the standard .net ValidatorTrim function simply does not work there, probably has something to do with the order of loading of scripts, or maybe our code isn't perfect (I did not try to write a test case, and standard RequiredFieldValidators work just fine). Since I do not have much time at all, I just wrote a simple TrimString function and now we can post messages in SkakkiNostri's forum using the Nokia Browser :)

Code:

// On Nokia Browser, the .net ValidatorTrim always returns null.
function TrimString(s) { return s.replace(/^\s+/g, "").replace(/\s+$/g, ""); }

lunedì 2 luglio 2007

The downlevel W3C html validator

ASP.net is smart enough to know which html content render to which browser, which is good. However there's a small flaw in the browser detection: the W3C html validator is detected as pre-DOM (and since it's not a real browser it logically is one of those), but that defeats the purpose of validating the output of a website.
Luckily it's quite easy to fix this issue. Just create a folder called App_Browsers in your website root and place a file called w3cvalidator.browser, containing the following code:

<browsers>
<!--
Browser capability file for the W3C validator
Sample User-Agent: "W3C_Validator/1.432.2.22"
-->
<browser id="w3cValidator" parentid="Default">
<identification>
<useragent match="^W3C_Validator">
</identification>
<capture>
<useragent match="^W3C_Validator/(?'version'(?'major'\d+)(?'minor'\.\d+)\w*).*">
</capture>
<capabilities>
<capability value="w3cValidator" name="browser">
<capability value="${major}" name="majorversion">
<capability value="${minor}" name="minorversion">
<capability value="${version}" name="version">
<capability value="1.0" name="w3cdomversion">
<capability value="true" name="xml">
<capability value="System.Web.UI.HtmlTextWriter" name="tagWriter">
</capabilities>
</browser>
</browsers>

One small side note. Initially I was placing this file along with another .browser file that I use to render PNG images with transparences (will talk about this little gem in the future). That one was called All.browser and rendered the right code for all browsers, but somehow it prevented the use of the w3cvalidator.browser file. Merging those two files into one did the trick.

mercoledì 13 giugno 2007

Preventing double postback

Today I had to make sure a certain button could not be pressed twice, to avoid processing errors. Initially I just set the disabled property to true, but that would not work since postback from disabled controls is inhibited. I invoked manually the postback, and it worked, but broke when validators prevented the postback. After a little bit of wondering, I crafted this small routine, to be placed in a Page-derived class for simplicity.

/// <summary>
/// Makes a certain button one-shot only,
/// preserving validator functionality
/// </summary>
/// <param name="btn">The button that can't be clicked twice</param>
protected void RegisterSinglePostButton(Button btn)
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("if (typeof(Page_ClientValidate) == 'function') { ");
sb.Append("if (Page_ClientValidate() == false) { return false; }} ");
sb.Append("this.disabled = true;");
sb.Append(ClientScript.GetPostBackEventReference(btn, ""));
sb.Append(";");
btn.Attributes.Add("onclick", sb.ToString());
}

It can be easily transformed in a control if needed, for further clarity.

sabato 9 giugno 2007

A small step for a man

Just a small accomplishment, yet I'm very happy. In the last days I was having a lot of 404 reports for a no-longer existing page on SkakkiNostri. The referrer page was null, so I had to find the source myself. I grep'd the source code, queried nearly every table in the database, but there was no link to that page there. So chances were a) some search engine b) some user.
Looking at the UserAgent/IP address, I quickly found it wasn't a search engine. So probably it had a user that dumbily had bookmarked that page, and now used my 404 error page to come back to the site. I have been thinking about what I could do to avoid this error, then I had a sudden realization: I could just track down the user using the cookies collection and send him a private message. Guess what? It worked. The user wasn't understanding what a 404 error was, but could access the site, so he was happy. Nonetheless explaining the situation to him we have been to fix the wrong link in the Favorites, and I'm no longer receiving emails. Well, not for that error :)

mercoledì 30 maggio 2007

Score another one for me!

So, after a clean format of my machine, I had this weird error in Microsoft Visual Studio 2005: it wouldn't compile one of my web projects. Compilation failed with the following error:

(O): Build (web): Unable to find the required module. (Exception from HRESULT: 0x8007007E)

As you can see, it says nothing about WHICH module is missing. I tried for a few days to fiddle with dependant assemblies, web.config settings, etc. but only today I have been able to investigate and fix it. I downloaded ProcessMonitor from the Microsoft site and after defining the appropriate filters I tracked down the missing assembly: it was MSVCR71.DLL. Just found a copy on some other location on the HD, placed it into C:\Windows\System32 and it worked flawlessly.

I feel like I deserve a reward. Going to buy an icecream! :-)