Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

AsyncMethods - An Improvement on Microsoft's ScriptMethods

0.00/5 (No votes)
10 Aug 2011 1  
A lightweight, secure, and better organized version of Microsoft's ScriptMethods.

Introduction

I love Microsoft's script services. I use them all the time in my day job. In fact, I love them so much that I replicated them in PHP for the work I do on the side. That being said, there's always room for improvement, and the more I've used them, the more I've come across ways where they could have been implemented better.

For those unfamiliar with script services, they're an extension of Web Services that expose any method in the Web Service decorated with a ScriptMethod attribute as invokable from the client side using AJAX. What I personally use them for is the business logic layer in my websites.

Background

In the current ASP.NET sites I develop, I use the following architecture:

SiteDesign.gif

I have a Master page with a global stylesheet and JavaScript file, along with jQuery and an <asp:ScriptManager> to support ScriptServices. Each page has its own stylesheet (to handle specific styles for the page), its own JavaScript file, and of course its code-behind file.

Contained within the ASPX page are empty containers and modal popup forms. 90% of the content on the page comes from invoking the Web Service and displaying the results. Inside the Web Service, requests to the database are converted into XML (normally using LINQ) and then transformed using XSLT files before being returned to the web page to be inserted into the containers. Presently, all my .aspx.vb code-behind files contain only (at most) a security check to redirect unauthorized users to the home page; all business logic exists in script services.

Originally, each web page had all the HTML and JavaScript contained in the .aspx file, with all CSS inline or located in the global stylesheet from the master page, but it got quite messy and unruly so I split it all into its separate pieces.

While this architecture very nicely separates presentation and business logic layers, it does have a few drawbacks:

  • The reference to the script service creates an additional connection to the web server to retrieve the dynamic JavaScript generated by it.
  • All methods decorated with a ScriptMethod attribute are exposed in the client script regardless of the user.
  • Security on each exposed method must be handled within the method (i.e., you have to determine if the user invoking the method actually has permission to do so inside the body of the method).
  • Script services are extensions of Web Services and can still be accessed as Web Services, which you might not really want.
  • In order to use script services, you must have an <asp:ScriptManager> (or the AJAX Control Toolkit equivalent) on the page (or Master page). This control converts into several additional <script> references to internal scripts which in turn require more connections to the server to retrieve them.

These problems aren't surmountable. For example, you can do a security check at the start of each method to ensure that the user has permission to invoke it. The risk comes where you forget to do so and some clever hacker that knows how to use the F12 key in IE8 or later starts manually invoking your script methods in ways you might not have predicted.

Most websites also have more than one flavour of user. A page in a forum site, for example, might have the public viewing the posts (with read only access), registered users who can only post and delete their own posts, moderators who can approve posts by users and delete any post, and administrators who can do all that plus manage the user list. It makes a lot of sense to write one "posting" page and just show/hide functionality based on who the user is rather than write variations of the same page based on the user. If you want to keep all the asynchronous code related to the page in one place however, using Microsoft's script services exposes the prototypes for all methods to all users (including the administrative ones!). Even with security checks in each method, revealing method prototypes to potential hackers probably isn't the best thing to do.

Being the type of programming wonk who likes to reinvent the wheel, I decided to recreate the functionality of ScriptMethod, but done in a way that works better for me and improves security.

The Requirements

I started off by defining some requirements of what I expected the end result to have:

  • The asynchronous methods should exist in the code-behind of the page that they apply to.
  • Asynchronous methods defined in the master page or in the page's ancestor classes should be exposed.
  • Asynchronous methods defined on a page should be able to be reused by other pages within the site with relative ease.
  • Methods declared with the Protected modifier should only be exposed when generating the client-side script for the page in which they are defined.
  • The developer should be able to easily identify the conditions under which methods are exposed to the client side (e.g., based on who the user is, etc.).
  • Our site already has references to jQuery and the JSON library.

Now that we know what we're aiming for, we can begin.

The Solution

The first thing we need to do is define two classes that inherit from Attribute. The first will be applied to any page that contains asynchronous methods, the second will be applied to any method the developer wishes to expose to asynchronous code.

<AttributeUsage(AttributeTargets.Class)> _
Public Class AsyncClassAttribute
    Inherits Attribute

    Private MyScriptClassName As String = ""
    Public ReadOnly Property ScriptClassName() As String
        Get
            Return MyScriptClassName
        End Get
    End Property

    Public Sub New(ByVal scriptClassName As String)
        MyScriptClassName = scriptClassName
    End Sub
End Class

The attribute is pretty basic. This attribute allows you to specify the name of the class/object that contains the methods in the resulting client-side script. The reason I don't simply match the class name of the page is that some page names might be reserved words in JavaScript, not to mention the fact that the class name for Default.aspx is something stupid like "Default_aspx", and who wants to use that?

Here is an example of how this attribute would be used:

<AsyncClass("Boogaloo")> _
Partial Class _Default
   Inherits System.Web.UI.Page

   ' Class stuff goes here
End Class

Assuming you exposed a method named HelloWorld in the above example, you would be able to invoke it from JavaScript like this:

Boogaloo.HelloWorld(/*args go here */);

The next attribute is the one that will be applied to methods you wish to expose:

<AttributeUsage
(AttributeTargets.Method)> _
Public Class AsyncMethodAttribute
Inherits Attribute
    Public Overridable Function IsValid(ByVal p As Page) As Boolean
        Return True
    End Function
End Class

This class looks pretty Spartan but we'll talk about it a bit later.

Using the Attributes

We have our attributes and we can even decorate classes and methods with them. But now what? How do these attributes actually do anything?

The answer is that attributes do nothing. But we can now look for those attributes and act based on their existence. And since our goal is to put our asynchronous methods in the script-behind of our pages, we should put our code in the page itself, or better yet, in a base class that all our pages can inherit from:

Imports Microsoft.VisualBasic
Imports System.Reflection
Imports System.IO
Imports System.Xml.Xsl

Public Class AsyncPage
       Inherits Page

    Protected Overrides Sub OnLoadComplete(ByVal e As System.EventArgs)
         If Request.QueryString("__asyncmethod") <> "" Then
            ExecuteAsyncMethod()
            Return
         ElseIf Request.QueryString("asyncscript") IsNot Nothing AndAlso _
                    Request.QueryString("asyncscript").ToLower() = "y" Then
            Response.ContentType = "text/javascript"

            BuildAsynchronousScriptCalls(False)
            Response.End()
            Return
        End If

       BuildAsynchronousScriptCalls(True)
       MyBase.OnLoadComplete(e)
    End Sub

Here's the start of our base class, AyncPage, which inherits from System.Web.UI.Page. The first thing we do is override the OnLoadComplete method from Page. Inside the method, we first check for whether or not a request for asynchronous method execution exists (more on that later). If not, we look to see if another page has requested the client-side script for our Public methods, in which case we dump out only the client-side script and none of the contents of the page. If neither condition is met, this is a regular page request so we need to generate the client-side script required to invoke our methods.

The next method in AsyncPage that we'll create is BuildAsynchronousScriptCalls. This private method has one argument, a Boolean value indicating whether the page request is for the page itself or for only the client-script.

Private Sub BuildAsynchronousScriptCalls(ByVal localPage As Boolean)
    Dim script As New StringBuilder()
    Dim name As String = Me.GetType().Name

    'Check if this class has been decorated with an AsyncClassAttribute
    'and use that for the name of the client-side object:
    For Each a As Attribute In Me.GetType().GetCustomAttributes(True)
        Try
           Dim attr As AsyncClassAttribute = a
           name = attr.ScriptClassName
        Catch ex As Exception
        End Try
    Next

    'Include methods from the master object, if we're on a local page:        
    If localPage AndAlso Master IsNot Nothing Then
        script.Append("var Master={__path:'")
        script.Append(Request.Url.ToString().Replace("'", "\'"))
        script.Append("'")
        ExtractClientScriptMethods(script, localPage, Master.GetType())
        script.Append("};")
    End If

    script.Append("var ")
    script.Append(name)
    script.Append("={__path:'")
    script.Append(Request.Url.ToString().Replace("'", "\'"))
    script.Append("'")

    'Include local methods:
    ExtractClientScriptMethods(script, localPage, Me.GetType())
    script.Append("};")

    If localPage Then
        ClientScript.RegisterClientScriptBlock(Me.GetType(), _
                     "AsyncScript", script.ToString(), True)
    Else
        Response.Write(script.ToString())
    End If
End Sub

This code checks for the AsyncClass attribute and extracts the client-side class name (defaulting to the current class' name otherwise), and using a StringBuilder, constructs the JavaScript method prototypes. If the request is local and this page has a master page, we call our ExtractClientScriptMethods method (see below), passing it the master page's type. Finally, we call ExtractClientScriptMethods on the current class' type. Once all the script has been generated, we either use the ClientScript class to register our script block into the page being generated or we simple dump the JavaScript using Response.Write.

Now we get into the guts of the JavaScript generation; ExtractClientScriptMethods:

Private Sub ExtractClientScriptMethods(ByVal script As StringBuilder, _
        ByVal localPage As Boolean, ByVal theType As Type)
     
   Dim argSetter As New StringBuilder

   For Each m As MethodInfo In theType.GetMethods(BindingFlags.Instance _
                 Or BindingFlags.NonPublic Or BindingFlags.Public)
       For Each a As Attribute In m.GetCustomAttributes(True)
           Try
               Dim attr As AsyncMethodAttribute = a

               'Check to see if this method is private or public and who the referrer is:
               If Not m.IsPublic AndAlso Not localPage Then
                   Exit For
               End If

               'Check to see if the current user is someone
               'who has permission to see this method:
               If Not attr.IsValid(Me) Then Exit For
               script.Append(",")
               script.Append(m.Name)
               script.Append(":function(")
               argSetter = New StringBuilder()

               'Load the arguments:
               For Each p As ParameterInfo In m.GetParameters()
                   script.Append(p.Name)
                   script.Append(",")

                   If argSetter.Length > 0 Then argSetter.Append(",")
                   argSetter.Append("'")
                   argSetter.Append(p.Name)
                   argSetter.Append("':")
                   argSetter.Append(p.Name)
               Next

               script.Append("onSuccess,onFailure,context){")

               For Each p As ParameterInfo In m.GetParameters()
                   Dim t As Type = p.ParameterType

                   script.Append("if(typeof(" & p.Name & _
                           ") == 'function'){throw 'Unable to cast function to " _
                           & t.ToString() & ", parameter " & p.Name & ".';}")

                   If t Is GetType(String) Then
                   ElseIf t Is GetType(Integer) Then
                   End If
               Next

               script.Append("__async(this.__path, '")
               script.Append(m.Name)
               script.Append("',{")
               script.Append(argSetter.ToString())
               script.Append("},onSuccess,onFailure,context);}")
           Catch ex As Exception
                'Do nothing!
           End Try
       Next
   Next
End Sub

In this method, we're using Reflection to get a list of all the methods in our class. We test to see if it has an AsyncMethodAttribute applied to it. To summarize, we build JavaScript methods named the same as our exposed methods, with the same number of parameters plus three additional ones: onSuccess, onFailure, and context. Those familiar with Microsoft's script methods will know that the first two are the JavaScript functions to be invoked when the asynchronous call succeeds or fails (respectively), and the third is a context variable that can contain whatever you'd like it to contain. All three of these additional parameters are optional.

The body of these JavaScript methods all contain a call to a method named __async. This is the one method you need to include in a global JavaScript file which uses jQuery's ajax method to asynchronously invoke our server-side methods:

function __async(path, method, args, onSuccess, onFailure, context)
{
    var delim = path.match(/\?/ig) ? '&' : '?';

    $.ajax({ type: 'POST',
        url: path + delim + '__asyncmethod=' + method,
        data: JSON.stringify(args).replace('&', '%26'),
        success: function(result, status, method)
    {
            if (result.status == 1)
            {
               onSuccess(result.result, context, method);
            }
            else
            {
                onFailure(result.result, context, method);
            }
        },
        error: function(request,status, errorThrown)
        {
            onFailure(request.responseText + '\n' + errorThrown, context, status);
        }
    });
}

Remember back in the OnLoadComplete event we first checked to see if an asynchronous method invocation was being requested? Well, if you look at the url argument of the ajax method, we include a query string item called "__asyncmethod" setting it to our method name. The arguments to the method are converted to a JSON string and passed as POST data. It's the existence of that query string setting that causes ExecuteAsyncMethod to be invoked:

Private Sub ExecuteAsyncMethod()

    Dim m As MethodInfo = Me.GetType().GetMethod(Request.QueryString("__asyncmethod"), _
          BindingFlags.Instance Or BindingFlags.Public Or BindingFlags.NonPublic)
    Dim ar As New AsyncResults
    Dim js As New System.Web.Script.Serialization.JavaScriptSerializer()
    Dim args As New List(Of Object)
    Dim targetObject As Object = Me
    Dim debugParamName As String = ""
    Dim debugParamValue As String = ""

    ar.status = 1
    ar.result = "null"

    If m Is Nothing AndAlso Master IsNot Nothing Then
        m = Master.GetType().GetMethod(Request.QueryString("__asyncmethod"), _
            BindingFlags.Instance Or BindingFlags.Public Or BindingFlags.NonPublic)
        targetObject = Master
    End If

    If m IsNot Nothing Then
        Dim accessGranted As Boolean = False

        'Check to make sure that the current user has permission to execute this method 
        'This prevents hackers from trying to invoke methods that they shouldn't):
        For Each a As Attribute In m.GetCustomAttributes(True)
            Try
                Dim attr As AsyncMethodAttribute = a

                If Not attr.IsValid(Me) Then
                      accessGranted = False
                      Exit For
                End If

                accessGranted = True
            Catch Ex As Exception
                'Do nothing
            End Try
        Next

        If Not accessGranted Then Throw New Exception("Access Denied")

        'Change the content-type to application/json, as we're returning
        'a JSON object that contains details about
        'the success or failure of the method
        Response.ContentType = "application/json"
        Try
            Dim referrerPath As String = Request.UrlReferrer.LocalPath

            If referrerPath.EndsWith("/") Then referrerPath &= "Default.aspx"
            If Not m.IsPublic AndAlso referrerPath.ToLower() <> _
                     Request.Url.LocalPath.ToLower() Then
                Throw New Exception("Access Denied")
            End If

            If Request.Form.Count > 0 Then
                Dim jp As New JsonParser()
                Dim params As Dictionary(Of String, Object) = _
                    jp.Parse(HttpUtility.UrlDecode(Request.Form.ToString()))
                For Each pi As ParameterInfo In m.GetParameters()
                    Dim destType As Type = pi.ParameterType
                    debugParamName = pi.Name
                    debugParamValue = params(pi.Name)

                    If Nullable.GetUnderlyingType (destType) IsNot Nothing Then
                            destType = Nullable.GetUnderlyingType(destType)
                    End If

                    If params(pi.Name) Is Nothing OrElse (params (pi.Name).GetType() Is _
                              GetType(String) AndAlso params(pi.Name) = "") Then
                        args.Add(Nothing)
                    Else
                        args.Add(System.Convert.ChangeType(params(pi.Name), destType))
                    End If
                Next
            End If

            ar.status = 1
            'Invoke the local method:
            ar.result = m.Invoke(targetObject, args.ToArray())
        Catch ex As Exception
            'Return exception information:
            ar.status = 0
            ar.result = ex.Message

            ex = ex.InnerException
            While ex IsNot Nothing
                ar.result = ex.Message
                ex = ex.InnerException
            End While
        End Try

        'Write the response and then terminate this page:
        Response.Write(js.Serialize(ar))
        Response.End()
    End If
End Sub

Private Class AsyncResults
    Public status As Integer
    Public result As Object
End Class

I won't go into the big, gory details of how this method works, it searches for the requested method by name both in the page and in the master page (if one exists). It verifies that the number and types of arguments match and attempts to invoke the method. Whether it succeeds or fails, this method returns the same result: an instance of the private class AsyncResults, serialized to JSON. The status member lets the AJAX call know if the return was successful or not (thus determining in the client-side whether onSuccess or onFailure gets called).

At this point, if any of this has made sense, you should be asking me how the system determines whether or not to expose/invoke the methods based on the current user. It all boils down to this code snippet from the above code block:

Dim attr As AsyncMethodAttribute = a

If Not attr.IsValid(Me) Then
    accessGranted = False
    Exit For
End If

Recall that the IsValid method in the AsyncMethodAttribute class returns True by default. I did this on purpose, as not all websites will have the same rules or user types. The expectation is that you, the developer, will create a class derived from AsyncMethodAttribute that accepts some value as part of its constructor that it uses in IsValid to determine whether or not the method should be exposed.

To (hopefully) make this clearer, I've included a sample web site that does just that.

The Sample

To demonstrate how this all works, I've created a sample website called AsyncMethodsDemo, attached to this article. It is a simple one-page message posting site with the following rules:

  • All approved, non-deleted posts are visible to everyone
  • Only registered users can create new posts
  • Standard user posts are not visible until approved by a moderator or administrator
  • Posts can be deleted by moderators, administrators, and the author of the post
  • Only administrators can add or delete users

Please don't complain about how dumb/ugly/lacking in data validation/etc., this posting site is; it's really there to demonstrate the asynchronous methods. For the database, I use a simple XML file in the App_Data folder. Yes, if multiple people access this site at the same time, data collisions will occur. Once again, it is not the point of the site.

Implementing the Security

In order to secure my methods, I needed to define an enumeration for the various types of users my system supports. This enumeration is defined in UserType.vb:

<Flags()> _
Public Enum UserType As Byte
    Anonymous = 1 << 0
    User = 1 << 1
    Moderator = 1 << 2
    Administrator = 1 << 3
    Everyone = Byte.MaxValue
End Enum

Then I create a new attribute class derived from AsyncMethodAttribute that makes use of this enumeration:

Public Class ForumAsyncMethodAttribute
    Inherits AsyncMethodAttribute

    Private MyValidUser As UserType = UserType.Everyone
    Public ReadOnly Property ValidUser() As UserType
        Get
            Return MyValidUser
        End Get
    End Property

    Public Sub New(ByVal validUser As UserType)
        MyValidUser = validUser
    End Sub

    Public Overrides Function IsValid(ByVal p As Page) As Boolean
        Dim fp As ForumsPage = CType(p, ForumsPage)
        If (fp.CurrentUser.UserType And ValidUser) = _
               fp.CurrentUser.UserType Then Return True
        Return False
    End Function
End Class

I then declared a new class inherited from AsyncPage which contains an instance of my SiteUser object. ForumAsyncMethodAttribute uses this class to check out the currently logged-in user and compare its UserType member to the attribute's ValidUser member. This is how our client-side script generator ensures that only methods available to the current user are exposed. Now in our Default.aspx.vb code-behind, we can declare methods like the following...

<ForumAsyncMethod(UserType.Administrator)> _
Protected Function GetUsers() As String
    Dim db As New Database

    Return TransformXml(db.Data.<Users>.Single, "Users.xslt")
End Function

...and the GetUsers call in JavaScript will only show up if the currently logged-in user is an administrator. Since we decorated the UserType enumeration with the Flags attribute, we can use bitwise operators to specify more than one valid user for a method. Recall that our requirements state that moderators and administrators can approve posts:

<ForumAsyncMethod(UserType.Moderator Or UserType.Administrator)> _
Protected Sub ApprovePost(ByVal id As Integer)
    Dim db As New Database
    db.Data.<Posts>.<Post>
    (id).@Approved = "1"
    db.Save()
End Sub

In the same vein, a UserType of Everyone will match against any user type, since its value in binary is 11111111.

On the client-side, we invoke our methods just as if we were invoking script methods:

function approvePost(id)
{
    if (!confirm('Are you sure you want to approve this post?')) return;
    Forums.ApprovePost(id, refreshPosts, alertResult);    
}

Feel free to log in as different types of users, then look at the source of Default.aspx to see how the JavaScript code changes. I hope you'll be entertained.

Enjoy this code and let me know if you have any comments or questions!

History

  • August 10, 2011 - Initial article.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here