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>