Partager via


SYSK 396: FxCop Rules for RESTful API

There are a few well known basic naming rules providing a consistent, understandable REST API services, e.g.

  • The scheme and host name are not case sensitive; however, the path and query should be treated as case sensitive.
  • Lowercase letters are preferred in URI paths. 
  • Do use hyphens (-) between words to improve readability of URI
  • Do not use underscore (_) in URIs as this character is often virtually indistinguishable when the content is underlined.

However, quite a few WebAPIs I’ve seen do not adhere to these conventions, so I created (quick and dirty, not fully tested and not production ready) FxCop rules that check for underscores and capital letters in WebAPI implementations.  Feel free to create a dll library type of project, add the files from the listings below and use it in your projects.

 

WebApiBaseNamingRule.cs

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

using Microsoft.FxCop.Sdk;

using System.Threading;

using YOURCOMPANY.CodeQuality;

using System.Web.Http.Routing;

using System.Web.Http;

 

namespace YOURCOMPANY.CodeQuality.WebAPI

{

    public abstract class WebApiBaseNamingRule : BaseIntrospectionRule

    {

        private static Dictionary<string, HttpRoute[]> KnownRoutes = new Dictionary<string, HttpRoute[]>();

        protected static string[] KnownVerbs = Enum.GetNames(typeof(System.Web.Mvc.HttpVerbs));

       

        public WebApiBaseNamingRule(string name, string resourceName)

            : base(name, resourceName, typeof(WebApiBaseNamingRule).Assembly)

        {

        }

 

        public abstract ProblemCollection Check(string checkTemplate, string routeTemplate);

 

        public override ProblemCollection Check(Member member)

        {

            var routes = GetRegisteredApiRoutes(member);

 

            if (member.DeclaringType.ConstructorName.ToString() == "WebApiConfig" &&

                member.NodeType == NodeType.Method&& 

                (member as Method).Parameters.Count == 1 && (member as Method).Parameters[0].Type.FullName == "System.Web.Http.HttpConfiguration")

            {

                foreach(var r in routes)

                {

                    // Only check literals, i.e. don't check placeholers as they will be replaced when WebAPI is actually used

                    string validateTemplate = RemoteRoutePlaceholders(r.RouteTemplate);

                    Check(validateTemplate, r.RouteTemplate);

                }

            }

            else if (member.DeclaringType.IsApiController()&&  member.IsPublic && !member.IsSpecialName)

            {

                string template = null;

                if (member.HasCustomAttribute("System.Web.Http.RouteAttribute"))

                {

                    var a = member.GetCustomAttribute("System.Web.Http.RouteAttribute");

                    

                    if (a.Expressions.Count > 0)

                    {

                        Literal l = a.Expressions[0] as Literal;

                        if (l != null)

                            template = l.Value as string;

                    }

                }

                if (string.IsNullOrEmpty(template) && member.NodeType == NodeType.Method)

                {

                    // Get matching template

                    string controllerName = RemoveSuffix(member.DeclaringType.Name.ToString(), "controller");

 

                    var requiredParamsArray = (from p in (member as Method).Parameters

                                               where p.IsOptional == false && p.IsOut == false   // note: IsIn is only set to true if parameter has [in] attribute

                                               select "{" + p.Name + "}").ToArray();

                    string requiredParams = string.Join("/", requiredParamsArray);

 

                    var action = "/" + member.Name.ToString();

 

                    if (IsHttpVerb(member.Name.ToString())) // Matching logic in System.Web.Http (packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll)

                        action = "";

 

                    template = "{controller}" + action +

                        (string.IsNullOrEmpty(requiredParams) ? "" : "/" + requiredParams);

 

                    foreach(var r in routes)

                    {

                        string rt = r.RouteTemplate;

                        if (rt.EndsWith(template))

                            template = rt.Substring(0, rt.Length - requiredParams.Length);

                    }

 

                }

                   

                if (!string.IsNullOrEmpty(template))

                {

                    string validateTemplate = RemoteRoutePlaceholders(template);

                    return Check(validateTemplate, template);

                }

                else

                {

                    return base.Check(member);

                }                   

            }

 

            return this.Problems;

        }

 

        private bool IsHttpVerb(string method)

        {

            bool result = false;

            if (!string.IsNullOrEmpty(method))

            {

                result = (from v in KnownVerbs

                          where string.Compare(method, v, true) == 0

                          select v).Any();

            }

            return result;

        }

 

        private string RemoteRoutePlaceholders(string template)

        {

            List<char> r = new List<char>();

            bool skip = false;

            foreach(var c in template.ToCharArray())

            {

                if (c == '{')

                    skip = true;

                if (!skip)

                    r.Add(c);

                if (c == '}')

                    skip = false;

            }

 

            string result = new string(r.ToArray());

            while (result.IndexOf("//") != -1)

            {

                result = result.Replace("//", "/");

            }

            return result;

        }

 

        private HttpRoute[] GetRegisteredApiRoutes(Member member)

        {

            List<HttpRoute> routeList = new List<HttpRoute>();

            var a = member.DeclaringType.ContainingAssembly().Name;

            lock (KnownRoutes)

            {

                if (!KnownRoutes.ContainsKey(a))

                {

                    // NOTE: this assumes the class name wasn't changed from one generated by VS project template

                    // TODO: make it more versatile

                    var webApiCofig = (from t in member.DeclaringType.ContainingAssembly().Types

                                       where t.ConstructorName.ToString() == "WebApiConfig"

                                       select t).FirstOrDefault();

                    var registerMethod = (from m in webApiCofig.Members

                                          where m.NodeType == NodeType.Method

                                          let tm = m as Method

                                          from p in tm.Parameters

                                          where tm.Parameters.Count == 1 && tm.Parameters[0].Type.FullName == "System.Web.Http.HttpConfiguration"

                                          select tm).FirstOrDefault();

 

                    var registeredRoutes = (from ms in registerMethod.Body.Statements

                                            where ms.NodeType == NodeType.Block

                                            let b = (ms as Block)

                                            from s in b.Statements

                                            where s.NodeType == NodeType.ExpressionStatement

                                            let ex = s as ExpressionStatement

                                            where ex.Expression is UnaryExpression

                                            let uex = ex.Expression as UnaryExpression

                                            where uex.Operand is Microsoft.FxCop.Sdk.NaryExpression&&  uex.Operand.Type.FullName == "System.Web.Http.Routing.IHttpRoute"

                                            select (uex.Operand as Microsoft.FxCop.Sdk.NaryExpression).Operands).ToArray();

 

                    foreach (var r in registeredRoutes)

                    {

                        HttpRoute route = null;

                        var operands = r.ToArray();

                      

                        // Skip the first operand which is MethodCall

                        for (int i = 1; i < operands.Length; i++)

                        {

                            // TODO: add handling for constraints

                            switch (i)

                            {

                                //case 1:

                                //    // Get this for debugging/tracing purposes

                                //    name = operands[i].ToString();

                                //    break;

                                case 2:

                                    //template = operands[i].ToString();

                                    route = new HttpRoute(operands[i].ToString());                                 

                                    break;

                                case 3:

                                    var operandName = operands[i].Type.Members.Last().Name;

                                    foreach (var o in (operands[i] as Construct).Operands)

                                    {

                                        route.Defaults.Add(operandName.ToString(), o);

                                        //if (o.NodeType == NodeType.MemberBinding)

                                        //{

                                        //    if ((o as MemberBinding).BoundMember.FullName == "System.Web.Http.RouteParameter.Optional")

                                        //    {

                                        //        string newTemplate = template.ReplaceOrdinal("/{" + operandName.ToString() + "}", "");

                                        //        routeList.Add(newTemplate);

                                        //    }

                                        //}

                                        //else if (o.NodeType == NodeType.Literal)

                                        //{

                                        //    string newTemplate = template.ReplaceOrdinal("{" + operandName.ToString() + "}", (o as Literal).Value.ToString());

                                        //    //System.Diagnostics.Debug.WriteLine(newTemplate);

                                        //    routeList.Add(newTemplate);

                                        //}

                                    }

                                    break;

                                case 4:

                                    //handler = operands[i].ToString();

                                    break;

                            }

                        }

 

                        routeList.Add(route);

                    }

 

                    KnownRoutes.Add(a, routeList.ToArray());

                }

            }

            return KnownRoutes[a];

        }

 

        private string RemoveSuffix(string name, string suffix, bool caseInsensitive = true)

        {

            string result = name;

            string s = caseInsensitive ? suffix.ToLower() : suffix;

            string n = caseInsensitive ? name.ToLower() : name;

            if (n.EndsWith(s))

            {

                result = name.Substring(0, name.Length - suffix.Length);

            }

            return result;

        }

 

        protected WordInfo[] Words(string template)

        {

            List<WordInfo> result = new List<WordInfo>();

            var wordParser = new WordParser(template, WordParserOptions.SplitCompoundWords);

 

            string word = null;

            int processedIndex = 0;

            while ((word = wordParser.NextWord()) != null)

            {

                int i = template.IndexOf(word, processedIndex);

                if (i > processedIndex)

                {

                    result.Add(new WordInfo(template.Substring(processedIndex, i - processedIndex), false));

                }

                result.Add(new WordInfo(word, true));

                processedIndex = i + word.Length;

            }

 

            if (processedIndex < template.Length)

                result.Add(new WordInfo(template.Substring(processedIndex), false));

 

            return result.ToArray();

        }

 

        protected struct WordInfo

        {

            public string Text;

            public bool IsWord;

 

            public WordInfo(string text, bool isWord)

            {

                Text = text;

                IsWord = isWord;

            }

        }

       

    }

}

 

 

WebApiRouteShouldNotContainCapitalLetters.cs

using Microsoft.FxCop.Sdk;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace YOURCOMPANY.CodeQuality.WebAPI

{

    // Important: this implementation only checks for controller and action names, not route part registered via config.Routes.MapHttpRoute

    public class WebApiRouteShouldNotContainCapitalLetters : WebApiBaseNamingRule

    {

        public WebApiRouteShouldNotContainCapitalLetters()

            : base("WebApiRouteShouldNotContainCapitalLetters", "YOURCOMPANY.CodeQuality.Rules")

        {

 

        }

        public override Microsoft.FxCop.Sdk.ProblemCollection Check(string checkTemplate, string routeTemplate)

        {

            bool isValid = true;

            string validTemplate = string.Empty;

            bool precededByWord = false;

            string validWord = string.Empty;

            foreach (var w in Words(checkTemplate))

            {

                isValid& = IsValid(w.Text, precededByWord, out validWord);

                validTemplate += validWord;

                precededByWord = w.IsWord;

            }

 

            if (!isValid)

            {

                validTemplate = routeTemplate.Replace(checkTemplate, validTemplate);

                this.Problems.Add(new Problem(this.GetResolution(routeTemplate, validTemplate)));

            }

 

            return this.Problems;

        }

 

        private bool IsValid(string word, bool preceededByWord, out string validWord)

        {

            bool result = true;

            List<char> validChars = new List<char>();

            foreach (char c in word.ToCharArray())

            {

                if (char.IsUpper(c))

                {

                    result = false;

                    if (preceededByWord)

                        validChars.Add('-');

                    validChars.Add(char.ToLower(c));

                }

                else

                {

                    validChars.Add(c);

                }

            }

 

            validWord = new string(validChars.ToArray());

 

            return result;

        }

    }   

}

 

 

WebApiRouteShouldNotContainUnderscores.cs

using Microsoft.FxCop.Sdk;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace YOURCOMPANY.CodeQuality.WebAPI

{

    public class WebApiRouteShouldNotContainUnderscores : WebApiBaseNamingRule

    {

        public WebApiRouteShouldNotContainUnderscores()

            : base("WebApiRouteShouldNotContainUnderscores", "YOURCOMPANY.CodeQuality.Rules")

        {

 

        }

        public override Microsoft.FxCop.Sdk.ProblemCollection Check(string checkTemplate, string routeTemplate)

        {

            if (checkTemplate.IndexOf('_') != -1)

            {

                this.Problems.Add(new Problem(this.GetResolution(routeTemplate)));

            }

 

            return this.Problems;

        }

    }

}

 

 

WebApiRouteShouldUseHyphenToSeparateWords.cs

using Microsoft.FxCop.Sdk;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace YOURCOMPANY.CodeQuality.WebAPI

{

    public class WebApiRouteShouldUseHyphenToSeparateWords : WebApiBaseNamingRule

    {

        public WebApiRouteShouldUseHyphenToSeparateWords()

            : base("WebApiRouteShouldUseHyphenToSeparateWords", "YOURCOMPANY.CodeQuality.Rules")

        {

 

        }

 

        public override Microsoft.FxCop.Sdk.ProblemCollection Check(string checkTemplate, string routeTemplate)

        {

            bool isValid = true;

            string validTemplate = string.Empty;

            var words = Words(checkTemplate);

            for (int i = 0; i < words.Length; i++)

            {

                WordInfo w = words[i];

                if (i > 0 && w.IsWord && words[i - 1].IsWord)

                {

                    isValid = false;

                    validTemplate += "-";

                }

                   

                validTemplate += w.Text.ToLower();

            }

 

            if (!isValid)

            {

                validTemplate = routeTemplate.Replace(checkTemplate, validTemplate);

                this.Problems.Add(new Problem(this.GetResolution(routeTemplate, validTemplate)));

            }

 

            return this.Problems;

        }

    }   

}

 

ClassFieldNamePrefixes.cs

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

using Microsoft.FxCop.Sdk;

using System.Threading;

 

namespace YOURCOMPANY.CodeQuality

{

    public class ClassFieldNamePrefixes : BaseIntrospectionRule

    {

        public ClassFieldNamePrefixes() :

            base("ClassFieldNamePrefixes", "YOURCOMPANY.CodeQuality.Rules", typeof(ClassFieldNamePrefixes).Assembly)

        {

        }

 

        public override ProblemCollection Check(Member member)

        {

            if (!(member.DeclaringType is ClassNode))

                return this.Problems;

 

            Field fld = member as Field;

            if (fld == null)

                return this.Problems;

 

            if (fld.IsStatic&& !fld.Name.Name.StartsWith("s_", StringComparison.Ordinal)&&

                fld.Name.Name != "__ENCList") // Ignore edit-and-continue debugging fields

            {

                this.Problems.Add(new Problem(this.GetNamedResolution("Static", fld.Name.Name)));

            }

            else if (!fld.Name.Name.StartsWith("_", StringComparison.Ordinal)&&

                fld.Name.Name != "__ENCList"&&  fld.Name.Name != "components")

            {

                this.Problems.Add(new Problem(this.GetNamedResolution("Instance", fld.Name.Name)));

            }

 

            return this.Problems;

        }

    }

}

 

 

ExtensionMethods \String.cs

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace YOURCOMPANY.CodeQuality

{

    public static class StringExtensionMethods

    {

        // Replace s1 with s2 in orig (find is case-insensitive)

        public static string ReplaceOrdinalIgnoreCase(this string orig, string s1, string s2)

        {

            string result = orig;

 

            int i1 = orig.ToLower().IndexOf(s1.ToLower());

            if (i1 != -1)

            {

                if (i1 == 0)

                    result = string.Empty;

                else

                    result = orig.Substring(0, i1);

 

                result += s2;

                int i2 = i1 + s1.Length;

                if (i2 < orig.Length)

                    result += orig.Substring(i2);

            }

 

            return result;

        }

    }

}

 

 

ExtensionMethods \TypeNode.cs

using Microsoft.FxCop.Sdk;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Text;

using System.Threading.Tasks;

 

namespace YOURCOMPANY.CodeQuality

{

    public static class TypeExtensionMethods

    {

        public static bool IsApiController(this TypeNode t)

        {

            bool result = false;

            do

            {

                t = t.BaseType;

                if (t != null && t.Name.Name == "ApiController")

                    result = true;

 

            } while (t != null && result == false);

 

            return result;

        }

    }

}

Rules.xml

<?xml version="1.0" encoding="utf-8"?>

<Rules FriendlyName="Code Quality Rules">

  <!-- WebAPI Rules.  Check ids range 1001 - 1999-->

  <Rule TypeName="WebApiRouteShouldNotContainUnderscores" Category="YourCompany.CodeQuality" CheckId="FA1001">

    <Name>Web API route should use hyphen (-) instead of undercore (_).</Name>

    <Description>

      Do not use underscore (_) in URIs as this character is often virtually indistinguishable when the content is underlined.

    </Description>

    <Url></Url>

    <Resolution>Use hyphen instead of underscore in route '{0}' .</Resolution>

    <MessageLevel Certainty="95">Warning</MessageLevel>

    <Email></Email>

    <FixCategories>Breaking</FixCategories>

    <Owner>Microsoft, Corp.</Owner>

  </Rule>

  <Rule TypeName="WebApiRouteShouldNotContainCapitalLetters" Category="YourCompany.CodeQuality" CheckId="FA1002">

    <Name>Lowercase letters are preferred in URI paths.</Name>

    <Description>

      Web API route should use hyphen (-) to separate compound words instead of Pascal or Camel case naming convention.

    </Description>

    <Url></Url>

    <Resolution>Change from {0} route to {1} .</Resolution>

    <MessageLevel Certainty="95">Warning</MessageLevel>

    <Email></Email>

    <FixCategories>Breaking</FixCategories>

    <Owner>Microsoft, Corp.</Owner>

  </Rule>

  <Rule TypeName="WebApiRouteShouldUseHyphenToSeparateWords" Category="YourCompany.CodeQuality" CheckId="FA1003">

    <Name>Use hyphens (-) between words to improve readability of URIs</Name>

    <Description>

      Web API route should use hyphen (-) to separate compound words

    </Description>

    <Url></Url>

    <Resolution>Change from {0} route to {1} .</Resolution>

    <MessageLevel Certainty="95">Warning</MessageLevel>

    <Email></Email>

    <FixCategories>Breaking</FixCategories>

    <Owner>Microsoft, Corp.</Owner>

  </Rule>

</Rules>